✨ feat: 完整应用重构 - 优化界面架构与用户体验
主要变更: - 重构应用界面:优化首页、登录、认证流程 - 新增用户档案模块:包含完整的档案管理组件 - 优化路由结构:重新组织页面布局和导航 - 改进API集成:新增活动、内容分类等API模块 - 删除冗余组件:清理不必要的文件和依赖 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
|
||||
import { useAuth } from '@/hooks/use-auth';
|
||||
import React, { createContext, useCallback, useContext, useEffect, useState } from 'react';
|
||||
import { LoginModal } from './login-modal';
|
||||
|
||||
export interface AuthContextType {
|
||||
|
||||
@@ -35,6 +35,105 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
const slideAnim = useRef(new Animated.Value(Dimensions.get("window").height)).current;
|
||||
const fadeAnim = useRef(new Animated.Value(0)).current;
|
||||
|
||||
// Web端CSS样式 - 覆盖autofill + 禁用密码自动填充
|
||||
useEffect(() => {
|
||||
if (Platform.OS === 'web') {
|
||||
const cssStyles = `
|
||||
/* 输入框基础样式 */
|
||||
.login-input-field,
|
||||
.login-input-field input,
|
||||
.login-input-field textarea {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
}
|
||||
|
||||
/* 自动填充状态覆盖 */
|
||||
.login-input-field:-webkit-autofill,
|
||||
.login-input-field:-webkit-autofill:hover,
|
||||
.login-input-field:-webkit-autofill:focus,
|
||||
.login-input-field:-webkit-autofill:active {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
.login-input-field input:-webkit-autofill,
|
||||
.login-input-field input:-webkit-autofill:hover,
|
||||
.login-input-field input:-webkit-autofill:focus,
|
||||
.login-input-field input:-webkit-autofill:active,
|
||||
.login-input-field textarea:-webkit-autofill,
|
||||
.login-input-field textarea:-webkit-autofill:hover,
|
||||
.login-input-field textarea:-webkit-autofill:focus,
|
||||
.login-input-field textarea:-webkit-autofill:active {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* Chrome内部autofill状态 */
|
||||
.login-input-field:-internal-autofill-selected,
|
||||
.login-input-field:-internal-autofill-previewed,
|
||||
.login-input-field input:-internal-autofill-selected,
|
||||
.login-input-field input:-internal-autofill-previewed,
|
||||
.login-input-field textarea:-internal-autofill-selected,
|
||||
.login-input-field textarea:-internal-autofill-previewed {
|
||||
background-color: #1a1d23 !important;
|
||||
color: #f5f6f8 !important;
|
||||
-webkit-text-fill-color: #f5f6f8 !important;
|
||||
caret-color: #f5f6f8 !important;
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
transition: background-color 9999s ease-in-out 0s;
|
||||
}
|
||||
|
||||
/* 文本选择高亮 */
|
||||
.login-input-field::selection {
|
||||
background-color: #f6474d;
|
||||
}
|
||||
|
||||
/* 密码输入框禁用自动填充 */
|
||||
input[type="password"][class*="login-input-field"] {
|
||||
-webkit-text-security: disc !important;
|
||||
}
|
||||
|
||||
/* 隐藏自动填充样式覆盖提示 */
|
||||
input[class*="login-input-field"]:-webkit-autofill,
|
||||
input[class*="login-input-field"]:-webkit-autofill:hover,
|
||||
input[class*="login-input-field"]:-webkit-autofill:focus,
|
||||
input[class*="login-input-field"]:-webkit-autofill:active {
|
||||
-webkit-box-shadow: 0 0 0 1000px #1a1d23 inset !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// 创建style标签
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML = cssStyles;
|
||||
style.id = 'login-autofill-styles';
|
||||
|
||||
// 添加到head
|
||||
const existingStyle = document.getElementById('login-autofill-styles');
|
||||
if (!existingStyle) {
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
const element = document.getElementById('login-autofill-styles');
|
||||
if (element) {
|
||||
element.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
@@ -132,27 +231,35 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
handleClose();
|
||||
};
|
||||
|
||||
// Web端CSS类名 - 仅使用CSS类名,避开类型限制
|
||||
const webInputProps = Platform.select({
|
||||
web: {
|
||||
className: 'login-input-field',
|
||||
},
|
||||
default: {},
|
||||
});
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="none">
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={styles.backdrop}
|
||||
onPress={handleBackdropPress}
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.overlay,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
<TouchableOpacity
|
||||
activeOpacity={1}
|
||||
style={styles.backdrop}
|
||||
onPress={handleBackdropPress}
|
||||
>
|
||||
<LinearGradient
|
||||
colors={["rgba(0, 0, 0, 0)", "rgba(0, 0, 0, 0.5)"]}
|
||||
style={StyleSheet.absoluteFillObject}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</Animated.View>
|
||||
|
||||
<Animated.View
|
||||
style={[
|
||||
@@ -191,7 +298,7 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
<View style={styles.form}>
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
|
||||
placeholder="yours@example.com"
|
||||
placeholderTextColor="#70737c"
|
||||
value={email}
|
||||
@@ -201,12 +308,13 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
keyboardType="email-address"
|
||||
editable={!isLoading}
|
||||
keyboardAppearance="dark"
|
||||
{...webInputProps}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={styles.inputWrapper}>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
style={[styles.input, Platform.select({ web: { outline: 'none' } as any })]}
|
||||
placeholder="email password"
|
||||
placeholderTextColor="#70737c"
|
||||
value={password}
|
||||
@@ -215,6 +323,9 @@ export function LoginModal({ visible, onClose }: LoginModalProps) {
|
||||
autoCorrect={false}
|
||||
editable={!isLoading}
|
||||
keyboardAppearance="dark"
|
||||
// 密码框专用属性
|
||||
textContentType={Platform.select({ web: undefined, default: "newPassword" })}
|
||||
{...webInputProps}
|
||||
/>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.7}
|
||||
@@ -366,6 +477,7 @@ const styles = StyleSheet.create({
|
||||
paddingVertical: 16,
|
||||
fontSize: 16,
|
||||
color: "#f5f6f8",
|
||||
backgroundColor: "transparent",
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
visibilityToggle: {
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { Image as ExpoImage } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { memo } from 'react';
|
||||
import {
|
||||
FlatList,
|
||||
ListRenderItemInfo,
|
||||
Pressable,
|
||||
Image as RNImage,
|
||||
StyleProp,
|
||||
StyleSheet,
|
||||
Text,
|
||||
View,
|
||||
ViewStyle,
|
||||
} from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export type CommunityItem = {
|
||||
id: string;
|
||||
@@ -26,22 +28,43 @@ type CommunityGridProps = {
|
||||
onPressAction?: (item: CommunityItem) => void;
|
||||
};
|
||||
|
||||
const EMPTY_ITEM: CommunityItem = {
|
||||
id: 'empty',
|
||||
title: '',
|
||||
image: '',
|
||||
chip: '',
|
||||
actionLabel: '',
|
||||
};
|
||||
|
||||
export function CommunityGrid({ items, onPressCard, onPressAction }: CommunityGridProps) {
|
||||
const renderItem = ({ item, index }: ListRenderItemInfo<CommunityItem>) => {
|
||||
const isEmpty = item.id === 'empty';
|
||||
const isLeftColumn = index % 2 === 0;
|
||||
|
||||
return (
|
||||
<CommunityCard
|
||||
item={item}
|
||||
onPressCard={onPressCard}
|
||||
onPressAction={onPressAction}
|
||||
style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}
|
||||
/>
|
||||
<View style={[styles.cardWrapper, isLeftColumn ? styles.cardLeft : styles.cardRight]}>
|
||||
{isEmpty ? (
|
||||
<View style={styles.emptyCard} />
|
||||
) : (
|
||||
<CommunityCard
|
||||
item={item}
|
||||
onPressCard={onPressCard}
|
||||
onPressAction={onPressAction}
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const data = items.length === 0
|
||||
? [EMPTY_ITEM, EMPTY_ITEM]
|
||||
: items.length === 1
|
||||
? [...items, EMPTY_ITEM]
|
||||
: items.slice(0, 4);
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={items}
|
||||
data={data}
|
||||
renderItem={renderItem}
|
||||
keyExtractor={(item) => item.id}
|
||||
numColumns={2}
|
||||
@@ -62,11 +85,15 @@ type CommunityCardProps = {
|
||||
const CommunityCard = memo(({ item, style, onPressCard, onPressAction }: CommunityCardProps) => {
|
||||
return (
|
||||
<Pressable onPress={() => onPressCard?.(item)} style={[styles.card, style]}>
|
||||
<View>
|
||||
<Image source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
|
||||
<View style={styles.imageWrapper}>
|
||||
<ExpoImage source={{ uri: item.image }} style={styles.cardImage} contentFit="cover" />
|
||||
<LinearGradient
|
||||
colors={['rgba(12, 14, 20, 0)', 'rgba(12, 14, 20, 0.85)']}
|
||||
style={styles.imageGradient}
|
||||
/>
|
||||
<Chip label={item.chip} />
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
</View>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<ActionButton label={item.actionLabel} onPress={() => onPressAction?.(item)} />
|
||||
</Pressable>
|
||||
);
|
||||
@@ -80,8 +107,8 @@ type ChipProps = {
|
||||
const Chip = memo(({ label }: ChipProps) => {
|
||||
return (
|
||||
<View style={styles.chip}>
|
||||
<Ionicons name="people-outline" size={14} color="#E5E9F2" style={styles.chipIcon} />
|
||||
<Text style={styles.chipText}>{label}</Text>
|
||||
<Ionicons name="people-outline" size={14} color="#F4F7FF" style={styles.chipIcon} />
|
||||
</View>
|
||||
);
|
||||
});
|
||||
@@ -95,7 +122,11 @@ type ActionButtonProps = {
|
||||
const ActionButton = memo(({ label, onPress }: ActionButtonProps) => {
|
||||
return (
|
||||
<Pressable onPress={onPress} style={({ pressed }) => [styles.button, pressed && styles.buttonPressed]}>
|
||||
<Ionicons name="flash-outline" size={16} color="#050505" style={styles.buttonIcon} />
|
||||
<RNImage
|
||||
source={require('@/assets/icons/start.svg')}
|
||||
style={styles.buttonIcon}
|
||||
resizeMode="contain"
|
||||
/>
|
||||
<Text style={styles.buttonText}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
@@ -119,61 +150,84 @@ const styles = StyleSheet.create({
|
||||
cardRight: {
|
||||
marginLeft: 8,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#101115',
|
||||
emptyCard: {
|
||||
backgroundColor: 'transparent',
|
||||
borderRadius: 20,
|
||||
padding: 14,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: '#2E3031',
|
||||
borderRadius: 20,
|
||||
padding: 0,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1C1D22',
|
||||
borderColor: '#1B1D24',
|
||||
},
|
||||
imageWrapper: {
|
||||
position: 'relative',
|
||||
borderRadius: 16,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 0,
|
||||
},
|
||||
cardImage: {
|
||||
width: '100%',
|
||||
height: 148,
|
||||
borderRadius: 16,
|
||||
marginBottom: 14,
|
||||
aspectRatio: 9 / 16,
|
||||
height: undefined,
|
||||
},
|
||||
imageGradient: {
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 88,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
letterSpacing: 0.8,
|
||||
position: 'absolute',
|
||||
left: 16,
|
||||
bottom: 18,
|
||||
fontSize: 14,
|
||||
letterSpacing: 1,
|
||||
fontWeight: '700',
|
||||
color: '#F5F8FF',
|
||||
marginBottom: 12,
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
chip: {
|
||||
position: 'absolute',
|
||||
top: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(5, 5, 5, 0.72)',
|
||||
borderRadius: 14,
|
||||
top: 14,
|
||||
right: 14,
|
||||
backgroundColor: 'rgba(27, 43, 78, 0.92)',
|
||||
borderRadius: 16,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 4,
|
||||
paddingVertical: 5,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chipIcon: {
|
||||
marginRight: 6,
|
||||
marginLeft: 6,
|
||||
},
|
||||
chipText: {
|
||||
color: '#C7FF00',
|
||||
fontSize: 11,
|
||||
fontWeight: '700',
|
||||
color: '#E4EBFF',
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.5,
|
||||
},
|
||||
button: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 10,
|
||||
borderRadius: 14,
|
||||
backgroundColor: '#B7FF2F',
|
||||
paddingVertical: 14,
|
||||
borderRadius: 16,
|
||||
borderWidth: 0,
|
||||
},
|
||||
buttonPressed: {
|
||||
opacity: 0.9,
|
||||
opacity: 0.85,
|
||||
},
|
||||
buttonIcon: {
|
||||
marginRight: 4,
|
||||
width: 18,
|
||||
height: 18,
|
||||
marginRight: 8,
|
||||
},
|
||||
buttonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#050505',
|
||||
color: '#C7FF00',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Image } from 'expo-image';
|
||||
import { memo } from 'react';
|
||||
import { Dimensions, FlatList, ListRenderItem, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Image } from 'expo-image';
|
||||
|
||||
const WINDOW_WIDTH = Dimensions.get('window').width;
|
||||
const CARD_HORIZONTAL_GUTTER = 18;
|
||||
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 64, 360);
|
||||
const CARD_WIDTH = Math.min(WINDOW_WIDTH - 124, 360);
|
||||
|
||||
export type FeatureItem = {
|
||||
id: string;
|
||||
@@ -20,7 +20,7 @@ type FeatureCarouselProps = {
|
||||
|
||||
export function FeatureCarousel({ items, onPress }: FeatureCarouselProps) {
|
||||
const cardInterval = CARD_WIDTH + CARD_HORIZONTAL_GUTTER;
|
||||
|
||||
|
||||
const renderItem: ListRenderItem<FeatureItem> = ({ item }) => <FeatureCard item={item} onPress={onPress} />;
|
||||
|
||||
return (
|
||||
@@ -54,7 +54,9 @@ const FeatureCard = memo(({ item, onPress }: FeatureCardProps) => {
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
|
||||
<View style={styles.imageContainer}>
|
||||
<Image source={{ uri: item.image }} style={styles.image} contentFit="cover" />
|
||||
</View>
|
||||
<View style={styles.cardContent}>
|
||||
<Text style={styles.cardTitle}>{item.title}</Text>
|
||||
<Text style={styles.cardSubtitle}>{item.subtitle}</Text>
|
||||
@@ -66,36 +68,47 @@ FeatureCard.displayName = 'FeatureCard';
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
contentContainer: {
|
||||
paddingVertical: 18,
|
||||
paddingVertical: 12,
|
||||
paddingRight: 24,
|
||||
},
|
||||
card: {
|
||||
width: CARD_WIDTH,
|
||||
marginRight: CARD_HORIZONTAL_GUTTER,
|
||||
borderRadius: 22,
|
||||
overflow: 'hidden',
|
||||
borderRadius: 24,
|
||||
padding: 0,
|
||||
paddingBottom: 16,
|
||||
borderWidth: 1,
|
||||
borderColor: '#1B1C20',
|
||||
backgroundColor: '#0F1013',
|
||||
shadowColor: '#000000',
|
||||
shadowOpacity: 0.28,
|
||||
shadowRadius: 18,
|
||||
shadowOffset: { width: 0, height: 12 },
|
||||
elevation: 12,
|
||||
},
|
||||
imageContainer: {
|
||||
borderRadius: 18,
|
||||
overflow: 'hidden',
|
||||
marginBottom: 16,
|
||||
},
|
||||
image: {
|
||||
width: '100%',
|
||||
height: 184,
|
||||
},
|
||||
cardContent: {
|
||||
padding: 18,
|
||||
backgroundColor: '#111115',
|
||||
paddingHorizontal: 2,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 16,
|
||||
fontWeight: '800',
|
||||
color: '#F4F8FF',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 6,
|
||||
letterSpacing: 0.8,
|
||||
letterSpacing: 1.2,
|
||||
},
|
||||
cardSubtitle: {
|
||||
fontSize: 13,
|
||||
color: '#9AA0AD',
|
||||
color: '#B4BBC5',
|
||||
lineHeight: 18,
|
||||
},
|
||||
});
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -57,8 +57,7 @@ export function FullscreenMediaModal({
|
||||
}
|
||||
}, [visible, currentIndex]);
|
||||
|
||||
// 处理媒体加载完成(移动平台)
|
||||
const handleMediaLoad = () => {
|
||||
const markMediaLoaded = () => {
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
@@ -156,11 +155,9 @@ export function FullscreenMediaModal({
|
||||
style={styles.mediaContainer}
|
||||
onPress={handleMediaPress}
|
||||
activeOpacity={1}
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<View
|
||||
style={styles.mediaWrapper}
|
||||
pointerEvents="auto"
|
||||
{...panResponder.panHandlers}
|
||||
>
|
||||
{/* 根据模板类型显示对应内容 */}
|
||||
@@ -179,7 +176,7 @@ export function FullscreenMediaModal({
|
||||
autoPlay={true}
|
||||
fullscreenMode={true}
|
||||
onPress={handleMediaPress}
|
||||
onReady={handleMediaLoad}
|
||||
onReady={(_status) => markMediaLoaded()}
|
||||
onWebLoadedData={handleWebVideoLoad}
|
||||
onError={handleMediaError}
|
||||
/>
|
||||
@@ -189,7 +186,7 @@ export function FullscreenMediaModal({
|
||||
source={{ uri: currentTemplate.coverImageUrl || '' }}
|
||||
style={styles.image}
|
||||
contentFit="contain"
|
||||
onLoad={handleMediaLoad}
|
||||
onLoad={() => markMediaLoaded()}
|
||||
onError={handleMediaError}
|
||||
placeholder={{ blurhash: 'L6PZfSi_.AyE_3t7t7R**0o#DgR4' }}
|
||||
transition={300}
|
||||
|
||||
228
components/profile/content-gallery.tsx
Normal file
228
components/profile/content-gallery.tsx
Normal file
@@ -0,0 +1,228 @@
|
||||
import React from 'react';
|
||||
import { View, FlatList, ActivityIndicator, RefreshControl, StyleSheet, Image } from 'react-native';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import type { TemplateGeneration } from '@/lib/api/template-generations';
|
||||
|
||||
export function ContentGallery({
|
||||
generations,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
}: {
|
||||
generations: TemplateGeneration[];
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
isLoadingMore: boolean;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
}) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
const renderItem = ({ item }: { item: TemplateGeneration }) => (
|
||||
<ContentItem palette={palette} generation={item} />
|
||||
);
|
||||
|
||||
const renderFooter = () => {
|
||||
if (isLoadingMore) {
|
||||
return (
|
||||
<View style={[styles.loadingMoreContainer, { backgroundColor: palette.background }]}>
|
||||
<ActivityIndicator size="small" color={palette.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<FlatList
|
||||
data={generations}
|
||||
renderItem={renderItem}
|
||||
ListFooterComponent={renderFooter}
|
||||
numColumns={2}
|
||||
keyExtractor={(item) => item.id}
|
||||
columnWrapperStyle={styles.columnWrapper}
|
||||
showsVerticalScrollIndicator={false}
|
||||
refreshControl={
|
||||
<RefreshControl
|
||||
refreshing={isRefreshing}
|
||||
onRefresh={onRefresh}
|
||||
tintColor={palette.accent}
|
||||
colors={[palette.accent]}
|
||||
/>
|
||||
}
|
||||
onEndReached={onLoadMore}
|
||||
onEndReachedThreshold={0.5}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContentItem({
|
||||
palette,
|
||||
generation,
|
||||
}: {
|
||||
palette: any;
|
||||
generation: TemplateGeneration;
|
||||
}) {
|
||||
const formatDate = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const renderMedia = () => {
|
||||
const mediaUrl = generation.resultUrl[0];
|
||||
if (generation.type === 'IMAGE') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<Image
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaImage}
|
||||
resizeMode="cover"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
} else if (generation.type === 'VIDEO') {
|
||||
return (
|
||||
<View style={styles.mediaContainer}>
|
||||
<VideoPlayer
|
||||
source={{ uri: mediaUrl }}
|
||||
style={styles.mediaVideo}
|
||||
shouldPlay={false}
|
||||
isLooping={true}
|
||||
isMuted={true}
|
||||
useNativeControls={false}
|
||||
showPoster={true}
|
||||
maxHeight={300}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.contentItem,
|
||||
{
|
||||
backgroundColor: palette.surface,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{renderMedia()}
|
||||
<View style={styles.contentInfo}>
|
||||
<ThemedText
|
||||
style={[styles.contentTitle, { color: palette.textPrimary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{generation.template.title}
|
||||
</ThemedText>
|
||||
<View style={styles.contentMeta}>
|
||||
<View style={styles.metaItem}>
|
||||
<MaterialIcons name="schedule" size={14} color={palette.textSecondary} />
|
||||
<ThemedText style={[styles.metaText, { color: palette.textSecondary }]}>
|
||||
{formatDate(generation.createdAt)}
|
||||
</ThemedText>
|
||||
</View>
|
||||
{generation.status !== 'completed' && (
|
||||
<View style={styles.statusBadge}>
|
||||
<ThemedText style={[styles.statusText, { color: palette.textSecondary }]}>
|
||||
{generation.status}
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
background: '#050505',
|
||||
surface: '#121216',
|
||||
border: '#1D1E24',
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
accent: '#B7FF2F',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
background: '#F7F8FB',
|
||||
surface: '#FFFFFF',
|
||||
border: '#E2E5ED',
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
accent: '#405CFF',
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
columnWrapper: {
|
||||
justifyContent: 'space-between',
|
||||
},
|
||||
contentItem: {
|
||||
flex: 1,
|
||||
borderRadius: 16,
|
||||
borderWidth: 1,
|
||||
marginBottom: 12,
|
||||
overflow: 'hidden',
|
||||
marginHorizontal: 6,
|
||||
},
|
||||
mediaContainer: {
|
||||
width: '100%',
|
||||
aspectRatio: 1,
|
||||
backgroundColor: '#000',
|
||||
},
|
||||
mediaImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
mediaVideo: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
contentInfo: {
|
||||
padding: 12,
|
||||
},
|
||||
contentTitle: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600',
|
||||
marginBottom: 8,
|
||||
},
|
||||
contentMeta: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
metaItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
metaText: {
|
||||
fontSize: 12,
|
||||
marginLeft: 4,
|
||||
},
|
||||
statusBadge: {
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 4,
|
||||
borderRadius: 4,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.1)',
|
||||
},
|
||||
statusText: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
textTransform: 'capitalize' as const,
|
||||
},
|
||||
loadingMoreContainer: {
|
||||
paddingVertical: 20,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
92
components/profile/content-tabs.tsx
Normal file
92
components/profile/content-tabs.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
|
||||
const TABS: { key: TabKey; label: string }[] = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'image', label: 'Image' },
|
||||
{ key: 'video', label: 'Video' },
|
||||
];
|
||||
|
||||
export function ContentTabs({
|
||||
activeTab,
|
||||
onChangeTab,
|
||||
}: {
|
||||
activeTab: TabKey;
|
||||
onChangeTab: (tab: TabKey) => void;
|
||||
}) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
return (
|
||||
<View style={[styles.tabsBar, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
||||
{TABS.map(tab => {
|
||||
const isActive = tab.key === activeTab;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={tab.key}
|
||||
activeOpacity={0.85}
|
||||
onPress={() => onChangeTab(tab.key)}
|
||||
style={[
|
||||
styles.tabButton,
|
||||
{
|
||||
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.tabLabel,
|
||||
{
|
||||
color: isActive ? palette.onAccent : palette.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{tab.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
pill: '#16171C',
|
||||
border: '#1D1E24',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#050505',
|
||||
textSecondary: '#8E9098',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
pill: '#E8EBF4',
|
||||
border: '#E2E5ED',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#FFFFFF',
|
||||
textSecondary: '#5E6474',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
tabsBar: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
borderWidth: 0,
|
||||
borderRadius: 999,
|
||||
padding: 0,
|
||||
},
|
||||
tabButton: {
|
||||
flex: 1,
|
||||
borderRadius: 999,
|
||||
paddingVertical: 10,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
tabLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
};
|
||||
17
components/profile/divider.tsx
Normal file
17
components/profile/divider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
import { View } from 'react-native';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function Divider() {
|
||||
const colorScheme = useColorScheme();
|
||||
const borderColor = colorScheme === 'dark' ? '#1D1E24' : '#E2E5ED';
|
||||
|
||||
return <View style={[styles.divider, { backgroundColor: borderColor }]} />;
|
||||
}
|
||||
|
||||
const styles = {
|
||||
divider: {
|
||||
height: 1,
|
||||
marginVertical: 24,
|
||||
},
|
||||
};
|
||||
9
components/profile/index.ts
Normal file
9
components/profile/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ProfileScreen, default } from './profile-screen';
|
||||
export { ProfileHeader } from './profile-header';
|
||||
export { ProfileIdentity } from './profile-identity';
|
||||
export { ContentTabs } from './content-tabs';
|
||||
export { ContentGallery } from './content-gallery';
|
||||
export { ProfileEmptyState } from './profile-empty-state';
|
||||
export { ProfileLoadingState } from './profile-loading-state';
|
||||
export { ProfileErrorState } from './profile-error-state';
|
||||
export { Divider } from './divider';
|
||||
333
components/profile/profile-edit-modal.tsx
Normal file
333
components/profile/profile-edit-modal.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Image,
|
||||
KeyboardAvoidingView,
|
||||
Modal,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
type ImageSourcePropType,
|
||||
} from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { deriveInitials } from '@/utils/profile-data';
|
||||
|
||||
type ProfileEditModalProps = {
|
||||
visible: boolean;
|
||||
avatarSource?: ImageSourcePropType;
|
||||
initialName: string;
|
||||
onClose: () => void;
|
||||
onSave: (payload: { name: string; avatar?: ImageSourcePropType }) => void;
|
||||
};
|
||||
|
||||
export function ProfileEditModal({
|
||||
visible,
|
||||
avatarSource,
|
||||
initialName,
|
||||
onClose,
|
||||
onSave,
|
||||
}: ProfileEditModalProps) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? palettes.dark : palettes.light;
|
||||
const [nameValue, setNameValue] = useState(initialName);
|
||||
const [avatarCandidate, setAvatarCandidate] = useState<ImageSourcePropType | undefined>(avatarSource);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
return;
|
||||
}
|
||||
setNameValue(initialName);
|
||||
setAvatarCandidate(avatarSource);
|
||||
}, [visible, initialName, avatarSource]);
|
||||
|
||||
const trimmedName = nameValue.trim();
|
||||
const initialTrimmedName = initialName.trim();
|
||||
const hasNameChanged = trimmedName !== initialTrimmedName;
|
||||
const avatarKey = getSourceKey(avatarCandidate);
|
||||
const initialAvatarKey = getSourceKey(avatarSource);
|
||||
const hasAvatarChanged = avatarKey !== initialAvatarKey;
|
||||
const isSaveDisabled = trimmedName.length === 0 || (!hasNameChanged && !hasAvatarChanged);
|
||||
|
||||
const handlePickAvatar = useCallback(async () => {
|
||||
try {
|
||||
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||
if (!permission.granted) {
|
||||
Alert.alert('Permission Required', 'Allow photo library access to choose a new portrait.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
allowsEditing: true,
|
||||
aspect: [1, 1],
|
||||
quality: 0.85,
|
||||
});
|
||||
|
||||
if (result.canceled || !result.assets?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const asset = result.assets[0];
|
||||
if (!asset.uri) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAvatarCandidate({ uri: asset.uri });
|
||||
} catch (error) {
|
||||
console.error('Failed to pick avatar', error);
|
||||
Alert.alert('Selection Failed', 'We could not open your photo library. Please try again.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
if (trimmedName.length === 0) {
|
||||
return;
|
||||
}
|
||||
onSave({
|
||||
name: trimmedName,
|
||||
avatar: avatarCandidate,
|
||||
});
|
||||
}, [avatarCandidate, onSave, trimmedName]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
transparent
|
||||
animationType="fade"
|
||||
visible={visible}
|
||||
onRequestClose={onClose}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<Pressable style={styles.backdrop} onPress={onClose} accessibilityRole="button" />
|
||||
<KeyboardAvoidingView
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
||||
style={styles.avoider}
|
||||
>
|
||||
<View style={[styles.card, { backgroundColor: palette.surface, borderColor: palette.frame }]}>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Close profile editor"
|
||||
onPress={onClose}
|
||||
style={[styles.closeButton, { backgroundColor: palette.buttonGhost, borderColor: palette.frame }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="close" size={18} color={palette.muted} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.avatarSection}>
|
||||
<View
|
||||
style={[
|
||||
styles.avatarShell,
|
||||
{ backgroundColor: palette.avatarBackdrop, borderColor: palette.frame },
|
||||
]}
|
||||
>
|
||||
{avatarCandidate ? (
|
||||
<Image source={avatarCandidate} style={styles.avatarImage} resizeMode="cover" />
|
||||
) : (
|
||||
<ThemedText style={[styles.avatarInitials, { color: palette.primary }]}>
|
||||
{deriveInitials(trimmedName || initialName)}
|
||||
</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Choose a new profile picture"
|
||||
onPress={handlePickAvatar}
|
||||
style={[styles.cameraButton, { backgroundColor: palette.accent }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="photo-camera" size={18} color={palette.onAccent} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<ThemedText style={[styles.modalTitle, { color: palette.primary }]}>
|
||||
Edit Profile
|
||||
</ThemedText>
|
||||
|
||||
<View style={[styles.fieldWrapper, { backgroundColor: palette.field, borderColor: palette.frame }]}>
|
||||
<TextInput
|
||||
value={nameValue}
|
||||
onChangeText={setNameValue}
|
||||
placeholder="Display name"
|
||||
placeholderTextColor={palette.placeholder}
|
||||
style={[styles.textInput, { color: palette.primary }]}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="done"
|
||||
onSubmitEditing={handleSave}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Save profile changes"
|
||||
onPress={handleSave}
|
||||
disabled={isSaveDisabled}
|
||||
style={[
|
||||
styles.saveButton,
|
||||
{
|
||||
backgroundColor: isSaveDisabled ? palette.buttonGhost : palette.accent,
|
||||
borderColor: isSaveDisabled ? palette.frame : 'transparent',
|
||||
borderWidth: isSaveDisabled ? StyleSheet.hairlineWidth : 0,
|
||||
},
|
||||
]}
|
||||
activeOpacity={0.9}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.saveLabel,
|
||||
{ color: isSaveDisabled ? palette.muted : palette.onAccent },
|
||||
]}
|
||||
>
|
||||
Save
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const palettes = {
|
||||
dark: {
|
||||
surface: '#121216',
|
||||
frame: '#1D1E24',
|
||||
avatarBackdrop: '#1F2026',
|
||||
field: '#18181D',
|
||||
primary: '#F6F7FA',
|
||||
muted: '#8E9098',
|
||||
placeholder: '#5B5D66',
|
||||
accent: '#B7FF2F',
|
||||
onAccent: '#050505',
|
||||
buttonGhost: '#101014',
|
||||
},
|
||||
light: {
|
||||
surface: '#FFFFFF',
|
||||
frame: '#D8DCE7',
|
||||
avatarBackdrop: '#E2E5EF',
|
||||
field: '#F3F5FC',
|
||||
primary: '#0F1320',
|
||||
muted: '#5E6474',
|
||||
placeholder: '#8C92A3',
|
||||
accent: '#405CFF',
|
||||
onAccent: '#FFFFFF',
|
||||
buttonGhost: '#EBEEF6',
|
||||
},
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backdrop: {
|
||||
...StyleSheet.absoluteFillObject,
|
||||
backgroundColor: 'rgba(5,5,5,0.68)',
|
||||
},
|
||||
avoider: {
|
||||
width: '100%',
|
||||
paddingHorizontal: 28,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 24,
|
||||
paddingTop: 32,
|
||||
paddingBottom: 28,
|
||||
borderWidth: 1,
|
||||
alignSelf: 'center',
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
},
|
||||
closeButton: {
|
||||
alignSelf: 'flex-end',
|
||||
width: 34,
|
||||
height: 34,
|
||||
borderRadius: 17,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
avatarSection: {
|
||||
alignItems: 'center',
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarShell: {
|
||||
width: 88,
|
||||
height: 88,
|
||||
borderRadius: 44,
|
||||
overflow: 'hidden',
|
||||
borderWidth: 1,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
avatarImage: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700',
|
||||
},
|
||||
cameraButton: {
|
||||
position: 'absolute',
|
||||
right: 18,
|
||||
bottom: 6,
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 18,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
modalTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginBottom: 20,
|
||||
},
|
||||
fieldWrapper: {
|
||||
borderRadius: 14,
|
||||
borderWidth: 1,
|
||||
marginBottom: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
textInput: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
saveButton: {
|
||||
borderRadius: 999,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
saveLabel: {
|
||||
fontSize: 16,
|
||||
fontWeight: '700',
|
||||
},
|
||||
});
|
||||
|
||||
function getSourceKey(source?: ImageSourcePropType): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
if (typeof source === 'number') {
|
||||
return String(source);
|
||||
}
|
||||
if (Array.isArray(source)) {
|
||||
return source.map(getSourceKey).join('|');
|
||||
}
|
||||
if ('uri' in source && source.uri) {
|
||||
return source.uri;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
127
components/profile/profile-empty-state.tsx
Normal file
127
components/profile/profile-empty-state.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
|
||||
export function ProfileEmptyState({ activeTab }: { activeTab: TabKey }) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
const copy = deriveEmptyCopy(activeTab);
|
||||
|
||||
return (
|
||||
<View style={styles.emptyWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.emptyGlyph,
|
||||
{
|
||||
backgroundColor: palette.glyphBackdrop,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="image" size={40} color={palette.accent} />
|
||||
</View>
|
||||
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>{copy.title}</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>{copy.subtitle}</ThemedText>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={() => router.push('/(tabs)/explore')}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
borderColor: palette.accent,
|
||||
backgroundColor: palette.elevated,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="auto-awesome" size={18} color={palette.accent} style={styles.generateIcon} />
|
||||
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Generate</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function deriveEmptyCopy(activeTab: TabKey) {
|
||||
if (activeTab === 'image') {
|
||||
return {
|
||||
title: 'No Images yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
if (activeTab === 'video') {
|
||||
return {
|
||||
title: 'No Videos yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and craft your first clip.',
|
||||
};
|
||||
}
|
||||
return {
|
||||
title: 'No Content yet',
|
||||
subtitle: 'Pick a style, enter a prompt, and generate your image.',
|
||||
};
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
glyphBackdrop: '#18181D',
|
||||
border: '#1D1E24',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
glyphBackdrop: '#E8EBF4',
|
||||
border: '#E2E5ED',
|
||||
accent: '#405CFF',
|
||||
elevated: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
emptyWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
generateButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
generateIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
};
|
||||
106
components/profile/profile-error-state.tsx
Normal file
106
components/profile/profile-error-state.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function ProfileErrorState({ onRetry }: { onRetry: () => void }) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.emptyWrap}>
|
||||
<View
|
||||
style={[
|
||||
styles.emptyGlyph,
|
||||
{
|
||||
backgroundColor: palette.glyphBackdrop,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="error-outline" size={40} color={palette.textSecondary} />
|
||||
</View>
|
||||
<ThemedText style={[styles.emptyTitle, { color: palette.textPrimary }]}>Failed to load</ThemedText>
|
||||
<ThemedText style={[styles.emptySubtitle, { color: palette.textSecondary }]}>
|
||||
There was an error loading your content
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
activeOpacity={0.9}
|
||||
onPress={onRetry}
|
||||
style={[
|
||||
styles.generateButton,
|
||||
{
|
||||
borderColor: palette.accent,
|
||||
backgroundColor: palette.elevated,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="refresh" size={18} color={palette.accent} style={styles.generateIcon} />
|
||||
<ThemedText style={[styles.generateLabel, { color: palette.accent }]}>Retry</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
glyphBackdrop: '#18181D',
|
||||
border: '#1D1E24',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
glyphBackdrop: '#E8EBF4',
|
||||
border: '#E2E5ED',
|
||||
accent: '#405CFF',
|
||||
elevated: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
emptyWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 32,
|
||||
},
|
||||
emptyGlyph: {
|
||||
width: 112,
|
||||
height: 112,
|
||||
borderRadius: 28,
|
||||
borderWidth: 1,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
marginBottom: 24,
|
||||
},
|
||||
emptyTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '700' as const,
|
||||
marginBottom: 6,
|
||||
},
|
||||
emptySubtitle: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
textAlign: 'center' as const,
|
||||
marginBottom: 28,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
generateButton: {
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 28,
|
||||
paddingVertical: 12,
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
generateIcon: {
|
||||
marginRight: 8,
|
||||
},
|
||||
generateLabel: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
};
|
||||
174
components/profile/profile-header.tsx
Normal file
174
components/profile/profile-header.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import { router } from 'expo-router';
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
type BillingMode = 'monthly' | 'lifetime';
|
||||
|
||||
const BILLING_OPTIONS: { key: BillingMode; label: string }[] = [
|
||||
{ key: 'monthly', label: '月付' },
|
||||
];
|
||||
|
||||
export function ProfileHeader({
|
||||
billingMode,
|
||||
onChangeBilling,
|
||||
credits,
|
||||
}: {
|
||||
billingMode: BillingMode;
|
||||
onChangeBilling: (mode: BillingMode) => void;
|
||||
credits: number;
|
||||
}) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.headerRow}>
|
||||
<TouchableOpacity
|
||||
onPress={() => router.push('/settings/account')}
|
||||
style={[styles.settingsButton, { backgroundColor: palette.pill }]}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
<MaterialIcons name="settings" size={24} color={palette.textPrimary} />
|
||||
</TouchableOpacity>
|
||||
<BillingBadge
|
||||
palette={palette}
|
||||
billingMode={billingMode}
|
||||
onChangeBilling={onChangeBilling}
|
||||
credits={credits}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function BillingBadge({
|
||||
palette,
|
||||
billingMode,
|
||||
onChangeBilling,
|
||||
credits,
|
||||
}: {
|
||||
palette: any;
|
||||
billingMode: BillingMode;
|
||||
onChangeBilling: (mode: BillingMode) => void;
|
||||
credits: number;
|
||||
}) {
|
||||
return (
|
||||
<View style={[styles.billingShell, { backgroundColor: palette.pill, borderColor: palette.border }]}>
|
||||
<View style={styles.billingOptions}>
|
||||
{BILLING_OPTIONS.map(option => {
|
||||
const isActive = option.key === billingMode;
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={option.key}
|
||||
onPress={() => onChangeBilling(option.key)}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.billingOption,
|
||||
{
|
||||
backgroundColor: isActive ? palette.tabActive : 'transparent',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<ThemedText
|
||||
style={[
|
||||
styles.billingLabel,
|
||||
{
|
||||
color: isActive ? palette.onAccent : palette.textSecondary,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{option.label}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
<View
|
||||
style={[
|
||||
styles.lightningShell,
|
||||
{
|
||||
backgroundColor: palette.elevated,
|
||||
borderColor: palette.border,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="flash-on" size={16} color={palette.accent} />
|
||||
<ThemedText style={[styles.lightningValue, { color: palette.textPrimary }]}>{credits}</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
pill: '#16171C',
|
||||
border: '#1D1E24',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#050505',
|
||||
textSecondary: '#8E9098',
|
||||
textPrimary: '#F6F7FA',
|
||||
accent: '#B7FF2F',
|
||||
elevated: '#101014',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
pill: '#E8EBF4',
|
||||
border: '#E2E5ED',
|
||||
tabActive: '#FFFFFF',
|
||||
onAccent: '#FFFFFF',
|
||||
textSecondary: '#5E6474',
|
||||
textPrimary: '#0F1320',
|
||||
accent: '#405CFF',
|
||||
elevated: '#F0F2F8',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
headerRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'space-between' as const,
|
||||
marginBottom: 28,
|
||||
},
|
||||
settingsButton: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
billingShell: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
padding: 4,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
},
|
||||
billingOptions: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
},
|
||||
billingOption: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
},
|
||||
billingLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
lightningShell: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
borderWidth: 1,
|
||||
borderRadius: 999,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
marginLeft: 8,
|
||||
},
|
||||
lightningValue: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600' as const,
|
||||
marginLeft: 6,
|
||||
},
|
||||
};
|
||||
184
components/profile/profile-identity.tsx
Normal file
184
components/profile/profile-identity.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
import React from 'react';
|
||||
import { View, TouchableOpacity, Image, type ImageSourcePropType } from 'react-native';
|
||||
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
import { deriveInitials, type IdentityStat } from '@/utils/profile-data';
|
||||
|
||||
export function ProfileIdentity({
|
||||
displayName,
|
||||
avatarSource,
|
||||
avatarSize,
|
||||
stats,
|
||||
onEdit,
|
||||
}: {
|
||||
displayName: string;
|
||||
avatarSource?: ImageSourcePropType;
|
||||
avatarSize: number;
|
||||
stats: IdentityStat[];
|
||||
onEdit: () => void;
|
||||
}) {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.identityRow}>
|
||||
<Avatar
|
||||
palette={palette}
|
||||
size={avatarSize}
|
||||
source={avatarSource}
|
||||
fallback={displayName}
|
||||
/>
|
||||
<View style={styles.identityDetails}>
|
||||
<View style={styles.nameRow}>
|
||||
<ThemedText numberOfLines={1} style={[styles.username, { color: palette.textPrimary }]}>
|
||||
{displayName}
|
||||
</ThemedText>
|
||||
<TouchableOpacity
|
||||
onPress={onEdit}
|
||||
activeOpacity={0.85}
|
||||
style={[
|
||||
styles.editButton,
|
||||
{
|
||||
borderColor: palette.border,
|
||||
backgroundColor: palette.surface,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<MaterialIcons name="edit" size={16} color={palette.textPrimary} style={styles.editIcon} />
|
||||
<ThemedText style={[styles.editLabel, { color: palette.textPrimary }]}>Edit</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.statRow}>
|
||||
{stats.map(stat => (
|
||||
<StatItem key={stat.key} palette={palette} label={stat.label} value={stat.value} />
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function Avatar({
|
||||
palette,
|
||||
size,
|
||||
source,
|
||||
fallback,
|
||||
}: {
|
||||
palette: any;
|
||||
size: number;
|
||||
source?: ImageSourcePropType;
|
||||
fallback: string;
|
||||
}) {
|
||||
const initials = deriveInitials(fallback);
|
||||
return (
|
||||
<View
|
||||
style={[
|
||||
styles.avatarShell,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: palette.avatarBackdrop,
|
||||
},
|
||||
]}
|
||||
>
|
||||
{source ? (
|
||||
<Image source={source} style={{ width: size, height: size, borderRadius: size / 2 }} resizeMode="cover" />
|
||||
) : (
|
||||
<ThemedText style={[styles.avatarInitials, { color: palette.textPrimary }]}>{initials}</ThemedText>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function StatItem({ palette, label, value }: { palette: any; label: string; value: number }) {
|
||||
return (
|
||||
<View style={styles.statItem}>
|
||||
<ThemedText style={[styles.statValue, { color: palette.textPrimary }]}>{value}</ThemedText>
|
||||
<ThemedText style={[styles.statLabel, { color: palette.textSecondary }]}>{label}</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textPrimary: '#F6F7FA',
|
||||
textSecondary: '#8E9098',
|
||||
avatarBackdrop: '#1F2026',
|
||||
surface: '#121216',
|
||||
border: '#1D1E24',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
textPrimary: '#0F1320',
|
||||
textSecondary: '#5E6474',
|
||||
avatarBackdrop: '#D5D8E2',
|
||||
surface: '#FFFFFF',
|
||||
border: '#E2E5ED',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
identityRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
marginBottom: 24,
|
||||
},
|
||||
avatarShell: {
|
||||
alignItems: 'center' as const,
|
||||
justifyContent: 'center' as const,
|
||||
},
|
||||
avatarInitials: {
|
||||
fontSize: 28,
|
||||
fontWeight: '700' as const,
|
||||
},
|
||||
identityDetails: {
|
||||
flex: 1,
|
||||
marginLeft: 18,
|
||||
},
|
||||
nameRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
marginBottom: 10,
|
||||
},
|
||||
username: {
|
||||
flex: 1,
|
||||
fontSize: 20,
|
||||
fontWeight: '700' as const,
|
||||
marginRight: 12,
|
||||
},
|
||||
editButton: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
editIcon: {
|
||||
marginRight: 6,
|
||||
},
|
||||
editLabel: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600' as const,
|
||||
},
|
||||
statRow: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'center' as const,
|
||||
},
|
||||
statItem: {
|
||||
flexDirection: 'row' as const,
|
||||
alignItems: 'baseline' as const,
|
||||
marginRight: 18,
|
||||
},
|
||||
statValue: {
|
||||
fontSize: 15,
|
||||
fontWeight: '700' as const,
|
||||
marginRight: 4,
|
||||
},
|
||||
statLabel: {
|
||||
fontSize: 13,
|
||||
fontWeight: '500' as const,
|
||||
textTransform: 'lowercase' as const,
|
||||
},
|
||||
};
|
||||
40
components/profile/profile-loading-state.tsx
Normal file
40
components/profile/profile-loading-state.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useColorScheme } from '@/hooks/use-color-scheme';
|
||||
|
||||
export function ProfileLoadingState() {
|
||||
const colorScheme = useColorScheme();
|
||||
const palette = colorScheme === 'dark' ? darkPalette : lightPalette;
|
||||
|
||||
return (
|
||||
<View style={styles.loadingWrap}>
|
||||
<ActivityIndicator size="large" color={palette.accent} />
|
||||
<ThemedText style={[styles.loadingText, { color: palette.textSecondary }]}>
|
||||
Loading content...
|
||||
</ThemedText>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const darkPalette = {
|
||||
textSecondary: '#8E9098',
|
||||
accent: '#B7FF2F',
|
||||
};
|
||||
|
||||
const lightPalette = {
|
||||
textSecondary: '#5E6474',
|
||||
accent: '#405CFF',
|
||||
};
|
||||
|
||||
const styles = {
|
||||
loadingWrap: {
|
||||
alignItems: 'center' as const,
|
||||
paddingTop: 48,
|
||||
paddingBottom: 48,
|
||||
},
|
||||
loadingText: {
|
||||
marginTop: 12,
|
||||
fontSize: 14,
|
||||
},
|
||||
};
|
||||
150
components/profile/profile-screen.tsx
Normal file
150
components/profile/profile-screen.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, useWindowDimensions, type ImageSourcePropType } from 'react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
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 { ContentGallery } from './content-gallery';
|
||||
import { ContentTabs } from './content-tabs';
|
||||
import { Divider } from './divider';
|
||||
import { ProfileEditModal } from './profile-edit-modal';
|
||||
import { ProfileEmptyState } from './profile-empty-state';
|
||||
import { ProfileErrorState } from './profile-error-state';
|
||||
import { ProfileHeader } from './profile-header';
|
||||
import { ProfileIdentity } from './profile-identity';
|
||||
import { ProfileLoadingState } from './profile-loading-state';
|
||||
|
||||
type TabKey = 'all' | 'image' | 'video';
|
||||
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 {
|
||||
generations,
|
||||
isLoading,
|
||||
error,
|
||||
isRefreshing,
|
||||
isLoadingMore,
|
||||
hasMore,
|
||||
handleRefresh,
|
||||
handleLoadMore,
|
||||
} = useProfileData(activeTab);
|
||||
|
||||
const horizontalPadding = width < 400 ? 20 : 24;
|
||||
const avatarSize = width < 400 ? 74 : 82;
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
setPresentedAvatar(avatarSource);
|
||||
}, [avatarSource]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
<ProfileLoadingState />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
<ProfileErrorState onRetry={handleRefresh} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.screen, { backgroundColor: stylesVars.background }]}>
|
||||
{insets.top > 0 && <View style={{ height: insets.top }} />}
|
||||
|
||||
<View style={{ paddingHorizontal: horizontalPadding }}>
|
||||
<ProfileHeader
|
||||
billingMode={billingMode}
|
||||
onChangeBilling={setBillingMode}
|
||||
credits={creditBalance}
|
||||
/>
|
||||
|
||||
<ProfileIdentity
|
||||
displayName={presentedDisplayName}
|
||||
avatarSource={presentedAvatar}
|
||||
avatarSize={avatarSize}
|
||||
stats={stats}
|
||||
onEdit={() => setIsEditingIdentity(true)}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
|
||||
<ContentTabs
|
||||
activeTab={activeTab}
|
||||
onChangeTab={setActiveTab}
|
||||
/>
|
||||
|
||||
<Divider />
|
||||
</View>
|
||||
|
||||
{generations.length === 0 ? (
|
||||
<ProfileEmptyState activeTab={activeTab} />
|
||||
) : (
|
||||
<View style={[styles.galleryContainer, { paddingHorizontal: horizontalPadding / 2 }]}>
|
||||
<ContentGallery
|
||||
generations={generations}
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={handleRefresh}
|
||||
isLoadingMore={isLoadingMore}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
<ProfileEditModal
|
||||
visible={isEditingIdentity}
|
||||
avatarSource={presentedAvatar}
|
||||
initialName={presentedDisplayName}
|
||||
onClose={() => setIsEditingIdentity(false)}
|
||||
onSave={({ name, avatar }) => {
|
||||
setPresentedDisplayName(name);
|
||||
if (avatar) {
|
||||
setPresentedAvatar(avatar);
|
||||
}
|
||||
setIsEditingIdentity(false);
|
||||
}}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const stylesVars = {
|
||||
background: '#050505',
|
||||
paddingBottom: 96,
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
},
|
||||
galleryContainer: {
|
||||
paddingTop: 8,
|
||||
},
|
||||
});
|
||||
|
||||
export default ProfileScreen;
|
||||
@@ -13,7 +13,7 @@ import { TemplateGeneration } from '@/lib/types/template-run';
|
||||
import { Image } from 'expo-image';
|
||||
import { VideoPlayer } from '@/components/video/video-player';
|
||||
import { useState } from 'react';
|
||||
import { cacheDirectory, documentDirectory, downloadAsync } from 'expo-file-system';
|
||||
import * as FileSystem from 'expo-file-system';
|
||||
import * as MediaLibrary from 'expo-media-library';
|
||||
|
||||
interface ResultDisplayProps {
|
||||
@@ -102,16 +102,14 @@ export function ResultDisplay({
|
||||
}
|
||||
|
||||
// 下载文件
|
||||
const targetDirectory = documentDirectory ?? cacheDirectory;
|
||||
if (!targetDirectory) {
|
||||
throw new Error('无法获取可写入的目录');
|
||||
}
|
||||
|
||||
const downloadResult = await downloadAsync(
|
||||
url,
|
||||
`${targetDirectory}generated_${Date.now()}_${index}.${getFileExtension(url)}`
|
||||
const targetDirectory = FileSystem.Paths.document ?? FileSystem.Paths.cache;
|
||||
const destination = new FileSystem.File(
|
||||
targetDirectory,
|
||||
`generated_${Date.now()}_${index}.${getFileExtension(url)}`
|
||||
);
|
||||
|
||||
const downloadResult = await FileSystem.File.downloadFileAsync(url, destination);
|
||||
|
||||
// 保存到媒体库
|
||||
if (result.type === 'IMAGE') {
|
||||
await MediaLibrary.saveToLibraryAsync(downloadResult.uri);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { View, StyleSheet, Animated, TouchableOpacity } from 'react-native';
|
||||
import { View, StyleSheet, Animated, TouchableOpacity, type ColorValue } from 'react-native';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
@@ -47,18 +47,18 @@ export function RunProgressView({ progress, onCancel }: RunProgressViewProps) {
|
||||
}
|
||||
}, [progress.status, pulseAnimation]);
|
||||
|
||||
const getStatusColor = () => {
|
||||
const getStatusColor = (): readonly [ColorValue, ColorValue] => {
|
||||
switch (progress.status) {
|
||||
case 'pending':
|
||||
return ['#FFA500', '#FF8C00']; // 橙色
|
||||
return ['#FFA500', '#FF8C00'] as const; // 橙色
|
||||
case 'running':
|
||||
return ['#4ECDC4', '#44A3A0']; // 青色
|
||||
return ['#4ECDC4', '#44A3A0'] as const; // 青色
|
||||
case 'completed':
|
||||
return ['#52B788', '#40916C']; // 绿色
|
||||
return ['#52B788', '#40916C'] as const; // 绿色
|
||||
case 'failed':
|
||||
return ['#FF6B6B', '#FF5252']; // 红色
|
||||
return ['#FF6B6B', '#FF5252'] as const; // 红色
|
||||
default:
|
||||
return ['#999999', '#666666'];
|
||||
return ['#999999', '#666666'] as const;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { ComponentProps } from 'react';
|
||||
import { OpaqueColorValue, type StyleProp, type TextStyle } from 'react-native';
|
||||
|
||||
type IconMapping = Record<SymbolViewProps['name'], ComponentProps<typeof MaterialIcons>['name']>;
|
||||
type IconSymbolName = keyof typeof MAPPING;
|
||||
export type IconSymbolName = keyof typeof MAPPING;
|
||||
|
||||
/**
|
||||
* Add your SF Symbols to Material Icons mappings here.
|
||||
@@ -20,6 +20,13 @@ const MAPPING = {
|
||||
'chevron.right': 'chevron-right',
|
||||
'person.fill': 'person',
|
||||
'person': 'person',
|
||||
'gearshape': 'settings',
|
||||
'person.circle': 'person-outline',
|
||||
'bell': 'notifications',
|
||||
'lock': 'lock',
|
||||
'info.circle': 'info',
|
||||
'exclamationmark.bubble': 'feedback',
|
||||
'arrow.right.square': 'logout',
|
||||
} as IconMapping;
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@ import { router } from 'expo-router';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import { IconSymbol } from '@/components/ui/icon-symbol';
|
||||
import { IconSymbol, type IconSymbolName } from '@/components/ui/icon-symbol';
|
||||
|
||||
interface SettingsListProps {
|
||||
onLogout: () => void;
|
||||
@@ -14,7 +14,7 @@ interface SettingsListProps {
|
||||
interface SettingItem {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: string;
|
||||
icon: IconSymbolName;
|
||||
onPress?: () => void;
|
||||
destructive?: boolean;
|
||||
}
|
||||
@@ -41,7 +41,8 @@ export function SettingsList({ onLogout }: SettingsListProps) {
|
||||
id: 'privacy',
|
||||
title: '隐私设置',
|
||||
icon: 'lock',
|
||||
onPress: () => router.push('/settings/privacy'),
|
||||
onPress: () =>
|
||||
Alert.alert('敬请期待', '隐私设置即将上线,敬请期待。'),
|
||||
},
|
||||
{
|
||||
id: 'about',
|
||||
@@ -53,7 +54,8 @@ export function SettingsList({ onLogout }: SettingsListProps) {
|
||||
id: 'feedback',
|
||||
title: '意见反馈',
|
||||
icon: 'exclamationmark.bubble',
|
||||
onPress: () => router.push('/settings/feedback'),
|
||||
onPress: () =>
|
||||
Alert.alert('感谢反馈', '请通过 support@bowong.cc 与我们联系。'),
|
||||
},
|
||||
{
|
||||
id: 'logout',
|
||||
@@ -162,4 +164,4 @@ const styles = StyleSheet.create({
|
||||
settingTitle: {
|
||||
fontSize: 16,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { AVPlaybackStatus, ResizeMode, Video } from 'expo-av';
|
||||
import { type VideoReadyForDisplayEvent, ResizeMode, Video } from 'expo-av';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
@@ -59,12 +59,10 @@ export function FullscreenVideoModal({
|
||||
}, [visible, autoPlay]);
|
||||
|
||||
// 处理视频加载完成
|
||||
const handleVideoReady = (status: AVPlaybackStatus) => {
|
||||
if (status.isLoaded) {
|
||||
setIsLoading(false);
|
||||
if (autoPlay) {
|
||||
videoRef.current?.playAsync();
|
||||
}
|
||||
const handleVideoReady = (_event: VideoReadyForDisplayEvent) => {
|
||||
setIsLoading(false);
|
||||
if (autoPlay) {
|
||||
videoRef.current?.playAsync();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Video, ResizeMode, AVPlaybackStatus } from 'expo-av';
|
||||
import { Image } from 'expo-image';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { useThemeColor } from '@/hooks/use-theme-color';
|
||||
import {
|
||||
extractVideoMetadata,
|
||||
@@ -59,7 +60,8 @@ export function VideoPlayer({
|
||||
onPress,
|
||||
fullscreenMode = false, // 新增:默认非全屏模式
|
||||
}: VideoPlayerProps) {
|
||||
const videoRef = useRef<Video>(null);
|
||||
const nativeVideoRef = useRef<Video | null>(null);
|
||||
const webVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [videoStatus, setVideoStatus] = useState<AVPlaybackStatus>();
|
||||
const [videoMetadata, setVideoMetadata] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
@@ -185,15 +187,29 @@ export function VideoPlayer({
|
||||
}
|
||||
|
||||
// 如果视频已加载但未播放,开始播放
|
||||
if (Platform.OS === 'web') {
|
||||
webVideoRef.current?.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoStatus?.isLoaded && !videoStatus.isPlaying && !useNativeControls) {
|
||||
videoRef.current?.playAsync();
|
||||
nativeVideoRef.current?.playAsync().catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
// 自动播放逻辑
|
||||
useEffect(() => {
|
||||
if (autoPlay && videoRef.current && videoStatus?.isLoaded) {
|
||||
videoRef.current.playAsync();
|
||||
if (!autoPlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Platform.OS === 'web') {
|
||||
webVideoRef.current?.play().catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoStatus?.isLoaded) {
|
||||
nativeVideoRef.current?.playAsync().catch(() => {});
|
||||
}
|
||||
}, [autoPlay, videoStatus]);
|
||||
|
||||
@@ -260,7 +276,7 @@ export function VideoPlayer({
|
||||
<>
|
||||
{Platform.OS === 'web' ? (
|
||||
<video
|
||||
ref={videoRef}
|
||||
ref={webVideoRef}
|
||||
src={source.uri}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@@ -291,7 +307,7 @@ export function VideoPlayer({
|
||||
/>
|
||||
) : (
|
||||
<Video
|
||||
ref={videoRef}
|
||||
ref={nativeVideoRef}
|
||||
source={source}
|
||||
style={styles.video}
|
||||
resizeMode={resizeMode}
|
||||
@@ -339,9 +355,9 @@ export function VideoPlayer({
|
||||
{!useNativeControls && isVideoLoaded && !isVideoPlaying && !isLoading && !hasVideoError && !fullscreenMode && (
|
||||
<View style={styles.playButtonOverlay}>
|
||||
<View style={styles.playButton}>
|
||||
<ThemedView style={styles.playIcon}>
|
||||
<ThemedText style={styles.playIcon}>
|
||||
{'▶️'}
|
||||
</ThemedView>
|
||||
</ThemedText>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
@@ -349,9 +365,9 @@ export function VideoPlayer({
|
||||
{/* 错误状态 - 只在非 Web 平台显示 */}
|
||||
{!isLoading && !isVideoLoaded && hasVideoError && Platform.OS !== 'web' && !shouldShowAsImage && (
|
||||
<View style={styles.errorOverlay}>
|
||||
<ThemedView style={styles.errorMessage}>
|
||||
<ThemedText style={styles.errorMessage}>
|
||||
{'❌'}
|
||||
</ThemedView>
|
||||
</ThemedText>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
@@ -439,4 +455,4 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
});
|
||||
|
||||
export default VideoPlayer;
|
||||
export default VideoPlayer;
|
||||
|
||||
Reference in New Issue
Block a user