fix: bug
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { authEvents } from '@/lib/api/client';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { LoginModal } from './login-modal';
|
||||
|
||||
@@ -51,6 +52,17 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
}, [isAuthenticated, showLoginModal, pendingCallback]);
|
||||
|
||||
// 监听 401 未授权事件
|
||||
useEffect(() => {
|
||||
const unsubscribe = authEvents.onUnauthorized(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
setShowLoginModal(true);
|
||||
}
|
||||
});
|
||||
|
||||
return unsubscribe;
|
||||
}, [isLoading, isAuthenticated]);
|
||||
|
||||
const value = {
|
||||
requireAuth,
|
||||
showLoginModal,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { memo, useCallback, useEffect, useRef } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native';
|
||||
|
||||
type Category = {
|
||||
id: string;
|
||||
@@ -13,18 +13,77 @@ type CategoryTabsProps = {
|
||||
};
|
||||
|
||||
export function CategoryTabs({ categories, activeId, onChange }: CategoryTabsProps) {
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const tabPositionsRef = useRef<Map<string, { x: number; width: number }>>(new Map());
|
||||
const containerWidthRef = useRef<number>(0);
|
||||
const { width: screenWidth } = useWindowDimensions();
|
||||
|
||||
const scrollToActiveTab = useCallback(() => {
|
||||
if (!activeId) {
|
||||
console.log('[CategoryTabs] No activeId');
|
||||
return;
|
||||
}
|
||||
|
||||
const position = tabPositionsRef.current.get(activeId);
|
||||
if (!position || !scrollViewRef.current) {
|
||||
console.log('[CategoryTabs] Missing position or ref:', { position, hasRef: !!scrollViewRef.current });
|
||||
return;
|
||||
}
|
||||
|
||||
const centerOffset = screenWidth / 2;
|
||||
const tabCenter = position.x + position.width / 2;
|
||||
const scrollX = tabCenter - centerOffset;
|
||||
|
||||
const maxScrollX = Math.max(0, containerWidthRef.current - screenWidth);
|
||||
const targetX = Math.max(0, Math.min(scrollX, maxScrollX));
|
||||
|
||||
console.log('[CategoryTabs] Scrolling to:', {
|
||||
activeId,
|
||||
position,
|
||||
screenWidth,
|
||||
tabCenter,
|
||||
targetX,
|
||||
containerWidth: containerWidthRef.current,
|
||||
});
|
||||
|
||||
scrollViewRef.current.scrollTo({
|
||||
x: targetX,
|
||||
animated: true,
|
||||
});
|
||||
}, [activeId, screenWidth]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(scrollToActiveTab, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [scrollToActiveTab]);
|
||||
|
||||
const handleContentSizeChange = useCallback((width: number) => {
|
||||
containerWidthRef.current = width;
|
||||
console.log('[CategoryTabs] Content size:', width);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
contentContainerStyle={styles.container}
|
||||
style={styles.scrollView}
|
||||
onContentSizeChange={handleContentSizeChange}
|
||||
>
|
||||
{categories.map(category => (
|
||||
{categories.map((category, index) => (
|
||||
<CategoryTabItem
|
||||
key={category.id}
|
||||
category={category}
|
||||
isActive={category.id === activeId}
|
||||
onPress={onChange}
|
||||
onLayout={(layout) => {
|
||||
tabPositionsRef.current.set(category.id, {
|
||||
x: layout.x,
|
||||
width: layout.width,
|
||||
});
|
||||
console.log(`[CategoryTabs] Tab layout [${category.label}]:`, layout);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -35,11 +94,19 @@ type CategoryTabItemProps = {
|
||||
category: Category;
|
||||
isActive: boolean;
|
||||
onPress: (id: string) => void;
|
||||
onLayout: (layout: { x: number; width: number }) => void;
|
||||
};
|
||||
|
||||
const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemProps) => {
|
||||
const CategoryTabItem = memo(({ category, isActive, onPress, onLayout }: CategoryTabItemProps) => {
|
||||
return (
|
||||
<Pressable onPress={() => onPress(category.id)} style={styles.tabButton}>
|
||||
<Pressable
|
||||
onPress={() => onPress(category.id)}
|
||||
style={styles.tabButton}
|
||||
onLayout={(event) => {
|
||||
const { x, width } = event.nativeEvent.layout;
|
||||
onLayout({ x, width });
|
||||
}}
|
||||
>
|
||||
<Text style={[styles.label, isActive && styles.labelActive]}>{category.label}</Text>
|
||||
<View style={[styles.indicator, isActive && styles.indicatorActive]} />
|
||||
</Pressable>
|
||||
@@ -48,6 +115,10 @@ const CategoryTabItem = memo(({ category, isActive, onPress }: CategoryTabItemPr
|
||||
CategoryTabItem.displayName = 'CategoryTabItem';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
scrollView: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 0,
|
||||
},
|
||||
container: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 6,
|
||||
|
||||
@@ -20,6 +20,8 @@ export type CommunityItem = {
|
||||
image: string;
|
||||
chip: string;
|
||||
actionLabel: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
type CommunityGridProps = {
|
||||
@@ -83,10 +85,16 @@ type CommunityCardProps = {
|
||||
};
|
||||
|
||||
const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
|
||||
const aspectRatio = item.width && item.height ? item.width / item.height : 9 / 16;
|
||||
|
||||
return (
|
||||
<Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}>
|
||||
<View style={styles.imageWrapper}>
|
||||
<ExpoImage source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
|
||||
<ExpoImage
|
||||
source={{ uri: item.image }}
|
||||
style={[styles.cardImage, { aspectRatio }]}
|
||||
contentFit="cover"
|
||||
/>
|
||||
<LinearGradient
|
||||
colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']}
|
||||
style={styles.imageGradient}
|
||||
@@ -169,7 +177,6 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
aspectRatio: 9 / 16,
|
||||
height: undefined,
|
||||
},
|
||||
imageGradient: {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { memo } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import Svg, { Path } from 'react-native-svg';
|
||||
|
||||
@@ -5,7 +6,7 @@ type HeaderProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function Header({ title = 'BESTAI' }: HeaderProps) {
|
||||
export const Header = memo(function Header({ title = 'BESTAI' }: HeaderProps) {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Svg width={89} height={13} viewBox="0 0 89 13" style={styles.logo}>
|
||||
@@ -13,7 +14,7 @@ export function Header({ title = 'BESTAI' }: HeaderProps) {
|
||||
</Svg>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { memo, PropsWithChildren } from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
type PageLayoutProps = PropsWithChildren<{
|
||||
backgroundColor?: string;
|
||||
topInset?: 'auto' | 'none' | number;
|
||||
horizontalPadding?: number | false;
|
||||
}>;
|
||||
|
||||
export function PageLayout({ children, backgroundColor = '#050505' }: PageLayoutProps) {
|
||||
export function PageLayout({
|
||||
children,
|
||||
backgroundColor = '#050505',
|
||||
topInset = 'auto',
|
||||
horizontalPadding = 24,
|
||||
}: PageLayoutProps) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const spacingTop = topInset === 'auto' ? insets.top : topInset === 'none' ? 0 : topInset;
|
||||
const spacingBottom = Math.max(insets.bottom, 16);
|
||||
const spacingHorizontal = horizontalPadding === false ? 0 : horizontalPadding;
|
||||
|
||||
return <View style={[styles.container, { paddingBottom: spacingBottom, backgroundColor }]}>{children}</View>;
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
paddingTop: spacingTop,
|
||||
paddingBottom: spacingBottom,
|
||||
paddingHorizontal: spacingHorizontal,
|
||||
backgroundColor
|
||||
}
|
||||
]}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatusBarSpacer() {
|
||||
export const StatusBarSpacer = memo(function StatusBarSpacer() {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
if (!insets.top) {
|
||||
@@ -22,11 +45,10 @@ export function StatusBarSpacer() {
|
||||
}
|
||||
|
||||
return <View style={{ height: insets.top }} />;
|
||||
}
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { View, useWindowDimensions, type ImageSourcePropType, ActivityIndicator } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { View, useWindowDimensions, type ImageSourcePropType, StyleSheet } from 'react-native';
|
||||
|
||||
import { PageLayout } from '@/components/bestai/layout';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import { useProfileData } from '@/hooks/use-profile-data';
|
||||
import { createStats, deriveAvatarSource, deriveDisplayName, deriveNumericValue } from '@/utils/profile-data';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { PROFILE_THEME } from '@/theme/profile';
|
||||
import { ContentGallery } from './content-gallery';
|
||||
import { ContentTabs } from './content-tabs';
|
||||
import { Divider } from './divider';
|
||||
@@ -23,12 +23,13 @@ type BillingMode = 'monthly' | 'lifetime';
|
||||
export function ProfileScreen() {
|
||||
const { user } = useAuth();
|
||||
const { width } = useWindowDimensions();
|
||||
const insets = useSafeAreaInsets();
|
||||
const [billingMode, setBillingMode] = useState<BillingMode>('monthly');
|
||||
const [activeTab, setActiveTab] = useState<TabKey>('all');
|
||||
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
||||
const [presentedDisplayName, setPresentedDisplayName] = useState(displayNameFromUser);
|
||||
const [isEditingIdentity, setIsEditingIdentity] = useState(false);
|
||||
const [editedIdentity, setEditedIdentity] = useState<{
|
||||
name?: string;
|
||||
avatar?: ImageSourcePropType;
|
||||
} | null>(null);
|
||||
|
||||
const {
|
||||
generations,
|
||||
@@ -42,24 +43,33 @@ export function ProfileScreen() {
|
||||
handleLoadMore,
|
||||
} = useProfileData(activeTab);
|
||||
|
||||
const horizontalPadding = width < 400 ? 20 : 24;
|
||||
const avatarSize = width < 400 ? 74 : 82;
|
||||
const isSmallScreen = width < PROFILE_THEME.breakpoints.small;
|
||||
const horizontalPadding = isSmallScreen
|
||||
? PROFILE_THEME.spacing.horizontal.small
|
||||
: PROFILE_THEME.spacing.horizontal.large;
|
||||
const avatarSize = isSmallScreen
|
||||
? PROFILE_THEME.avatar.size.small
|
||||
: PROFILE_THEME.avatar.size.large;
|
||||
|
||||
const displayNameFromUser = deriveDisplayName(user) ?? 'prairie_pufferfish_the';
|
||||
const avatarSource = deriveAvatarSource(user);
|
||||
const creditBalance = deriveNumericValue((user as Record<string, unknown>)?.credits);
|
||||
const stats = createStats(user);
|
||||
const [presentedAvatar, setPresentedAvatar] = useState<ImageSourcePropType | undefined>(avatarSource);
|
||||
|
||||
useEffect(() => {
|
||||
setPresentedDisplayName(displayNameFromUser);
|
||||
}, [displayNameFromUser]);
|
||||
const presentedDisplayName = editedIdentity?.name ?? displayNameFromUser;
|
||||
const presentedAvatar = editedIdentity?.avatar ?? avatarSource;
|
||||
|
||||
useEffect(() => {
|
||||
setPresentedAvatar(avatarSource);
|
||||
}, [avatarSource]);
|
||||
const headerContainerStyle = useMemo(
|
||||
() => ({
|
||||
paddingHorizontal: horizontalPadding,
|
||||
paddingBottom: PROFILE_THEME.spacing.headerBottom,
|
||||
}),
|
||||
[horizontalPadding]
|
||||
);
|
||||
|
||||
const headerComponent = useMemo(
|
||||
() => (
|
||||
<View style={{ paddingHorizontal: horizontalPadding, paddingBottom: 16 }}>
|
||||
<View style={headerContainerStyle}>
|
||||
<ProfileHeader
|
||||
billingMode={billingMode}
|
||||
onChangeBilling={setBillingMode}
|
||||
@@ -86,7 +96,7 @@ export function ProfileScreen() {
|
||||
</View>
|
||||
),
|
||||
[
|
||||
horizontalPadding,
|
||||
headerContainerStyle,
|
||||
billingMode,
|
||||
creditBalance,
|
||||
presentedDisplayName,
|
||||
@@ -100,33 +110,29 @@ export function ProfileScreen() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
|
||||
<ProfileLoadingState />
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
|
||||
<ProfileErrorState onRetry={handleRefresh} />
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
|
||||
<PageLayout backgroundColor={PROFILE_THEME.colors.background} horizontalPadding={false}>
|
||||
{isLoading && generations.length === 0 ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.flexContainer}>
|
||||
{headerComponent}
|
||||
<ContentSkeleton />
|
||||
</View>
|
||||
) : generations.length === 0 && !isTabSwitching ? (
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.flexContainer}>
|
||||
{headerComponent}
|
||||
<ProfileEmptyState activeTab={activeTab} />
|
||||
</View>
|
||||
@@ -148,24 +154,16 @@ export function ProfileScreen() {
|
||||
initialName={presentedDisplayName}
|
||||
onClose={() => setIsEditingIdentity(false)}
|
||||
onSave={({ name, avatar }) => {
|
||||
setPresentedDisplayName(name);
|
||||
if (avatar) {
|
||||
setPresentedAvatar(avatar);
|
||||
}
|
||||
setEditedIdentity({ name, avatar });
|
||||
setIsEditingIdentity(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesVars = {
|
||||
background: '#050505',
|
||||
paddingBottom: 96,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flexContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user