From c00a8c6ff6461d9f786c18704cf42edcf2973173 Mon Sep 17 00:00:00 2001 From: baizhi958216 <1475289190@qq.com> Date: Tue, 30 Jun 2026 13:16:15 +0800 Subject: [PATCH] feat: build mobile app shell Signed-off-by: baizhi958216 <1475289190@qq.com> --- metro.config.js | 20 + package.json | 2 + src/app/create.tsx | 342 ++++++++++++++++ src/app/explore.tsx | 180 --------- src/app/index.tsx | 343 ++++++++++++---- src/app/profile.tsx | 676 ++++++++++++++++++++++++++++++++ src/components/app-tabs.tsx | 38 +- src/components/app-tabs.web.tsx | 163 ++++---- src/lib/model-config.ts | 56 +++ tsconfig.json | 6 + 10 files changed, 1474 insertions(+), 352 deletions(-) create mode 100644 metro.config.js create mode 100644 src/app/create.tsx delete mode 100644 src/app/explore.tsx create mode 100644 src/app/profile.tsx create mode 100644 src/lib/model-config.ts diff --git a/metro.config.js b/metro.config.js new file mode 100644 index 0000000..47d6a08 --- /dev/null +++ b/metro.config.js @@ -0,0 +1,20 @@ +const path = require('path'); +const { getDefaultConfig } = require('expo/metro-config'); + +const projectRoot = __dirname; +const workspaceRoot = path.resolve(projectRoot, '..'); + +const config = getDefaultConfig(projectRoot); + +config.watchFolders = [workspaceRoot]; +config.resolver.alias = { + ...config.resolver.alias, + '@infiplot/core': path.resolve(workspaceRoot, 'packages/core/src/index.ts'), + '@infiplot/types': path.resolve(workspaceRoot, 'packages/types/src/index.ts'), +}; +config.resolver.nodeModulesPaths = [ + path.resolve(projectRoot, 'node_modules'), + path.resolve(workspaceRoot, 'node_modules'), +]; + +module.exports = config; diff --git a/package.json b/package.json index 3a75f4a..a350647 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,8 @@ "version": "1.0.0", "dependencies": { "@expo/ui": "~56.0.18", + "@infiplot/core": "workspace:*", + "@infiplot/types": "workspace:*", "expo": "~56.0.12", "expo-constants": "~56.0.18", "expo-device": "~56.0.4", diff --git a/src/app/create.tsx b/src/app/create.tsx new file mode 100644 index 0000000..7a468b3 --- /dev/null +++ b/src/app/create.tsx @@ -0,0 +1,342 @@ +import { creationOptions, defaultStoryDraft } from '@infiplot/core'; +import { SymbolView } from 'expo-symbols'; +import { useState } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { BottomTabInset } from '@/constants/theme'; + +export default function CreateScreen() { + const [world, setWorld] = useState(defaultStoryDraft.worldSetting); + const [style, setStyle] = useState(defaultStoryDraft.styleGuide); + const [template, setTemplate] = useState(creationOptions.templates[1]); + const [ratio, setRatio] = useState(creationOptions.ratios[0]); + const [rhythm, setRhythm] = useState(creationOptions.rhythms[0]); + + const ready = world.trim().length > 10 && style.trim().length > 5; + + return ( + + + + + AI 剧情创作 + 写下两段文字,生成可刷的短剧。 + + + + + + + + 题材模板 + + + + + + 世界设定 + {world.length} + + + + + + + 视觉风格 + {style.length} + + + + + + + 画面比例 + + + + 叙事节奏 + + + + + + 生成流程 + + {['故事圣经', '分镜脚本', '角色视觉', '首集预览'].map((step, index) => ( + + {index + 1} + {step} + + ))} + + + + + + [ + styles.generateButton, + !ready && styles.disabledButton, + pressed && ready && styles.pressed, + ]}> + + {ready ? '生成第一集' : '继续补充设定'} + + + + ); +} + +function ChipRow({ + options, + value, + onChange, + compact, +}: { + options: readonly string[]; + value: string; + onChange: (value: string) => void; + compact?: boolean; +}) { + return ( + + {options.map((option) => { + const selected = option === value; + return ( + onChange(option)} + style={({ pressed }) => [ + styles.chip, + compact && styles.compactChip, + selected && styles.selectedChip, + pressed && styles.pressed, + ]}> + {option} + + ); + })} + + ); +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: '#09090b', + }, + scroll: { + flex: 1, + }, + content: { + padding: 18, + paddingBottom: BottomTabInset + 112, + gap: 18, + }, + header: { + flexDirection: 'row', + alignItems: 'flex-start', + justifyContent: 'space-between', + gap: 18, + paddingTop: 8, + }, + eyebrow: { + color: '#ff4d6d', + fontSize: 12, + fontWeight: '900', + marginBottom: 8, + }, + title: { + color: '#fff', + fontSize: 30, + lineHeight: 36, + fontWeight: '900', + maxWidth: 300, + }, + headerIcon: { + width: 46, + height: 46, + borderRadius: 23, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.12)', + }, + section: { + gap: 10, + }, + label: { + color: 'rgba(255,255,255,0.7)', + fontSize: 13, + fontWeight: '900', + }, + chips: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + compactChips: { + gap: 8, + }, + chip: { + minHeight: 38, + borderRadius: 19, + paddingHorizontal: 14, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.09)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + compactChip: { + minHeight: 32, + paddingHorizontal: 10, + }, + selectedChip: { + backgroundColor: '#fff', + borderColor: '#fff', + }, + chipText: { + color: '#fff', + fontSize: 13, + fontWeight: '800', + }, + selectedChipText: { + color: '#09090b', + }, + fieldBlock: { + gap: 10, + }, + fieldHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + counter: { + color: 'rgba(255,255,255,0.42)', + fontSize: 12, + fontWeight: '800', + }, + textArea: { + minHeight: 158, + borderRadius: 8, + padding: 14, + color: '#fff', + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + backgroundColor: 'rgba(255,255,255,0.08)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.1)', + }, + shortArea: { + minHeight: 116, + }, + twoColumns: { + flexDirection: 'row', + gap: 12, + }, + optionCard: { + flex: 1, + gap: 12, + borderRadius: 8, + padding: 12, + backgroundColor: 'rgba(255,255,255,0.07)', + }, + previewCard: { + borderRadius: 8, + padding: 14, + gap: 12, + backgroundColor: 'rgba(255,255,255,0.07)', + }, + previewTitle: { + color: '#fff', + fontSize: 15, + fontWeight: '900', + }, + steps: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 10, + }, + step: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + minWidth: '46%', + }, + stepIndex: { + width: 22, + height: 22, + borderRadius: 11, + overflow: 'hidden', + textAlign: 'center', + lineHeight: 22, + color: '#09090b', + backgroundColor: '#b8ff5d', + fontSize: 12, + fontWeight: '900', + }, + stepText: { + color: 'rgba(255,255,255,0.82)', + fontSize: 13, + fontWeight: '800', + }, + footer: { + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + paddingHorizontal: 18, + paddingTop: 12, + paddingBottom: BottomTabInset + 14, + backgroundColor: 'rgba(9,9,11,0.96)', + }, + generateButton: { + height: 52, + borderRadius: 26, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 8, + backgroundColor: '#fff', + }, + disabledButton: { + opacity: 0.42, + }, + generateText: { + color: '#050505', + fontSize: 16, + fontWeight: '900', + }, + pressed: { + opacity: 0.76, + }, +}); diff --git a/src/app/explore.tsx b/src/app/explore.tsx deleted file mode 100644 index 2934085..0000000 --- a/src/app/explore.tsx +++ /dev/null @@ -1,180 +0,0 @@ -import { Image } from 'expo-image'; -import { SymbolView } from 'expo-symbols'; -import { Platform, Pressable, ScrollView, StyleSheet } from 'react-native'; -import { useSafeAreaInsets } from 'react-native-safe-area-context'; - -import { ExternalLink } from '@/components/external-link'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { Collapsible } from '@/components/ui/collapsible'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; -import { useTheme } from '@/hooks/use-theme'; - -export default function TabTwoScreen() { - const safeAreaInsets = useSafeAreaInsets(); - const insets = { - ...safeAreaInsets, - bottom: safeAreaInsets.bottom + BottomTabInset + Spacing.three, - }; - const theme = useTheme(); - - const contentPlatformStyle = Platform.select({ - android: { - paddingTop: insets.top, - paddingLeft: insets.left, - paddingRight: insets.right, - paddingBottom: insets.bottom, - }, - web: { - paddingTop: Spacing.six, - paddingBottom: Spacing.four, - }, - }); - - return ( - - - - Explore - - This starter app includes example{'\n'}code to help you get started. - - - - pressed && styles.pressed}> - - Expo documentation - - - - - - - - - - This app has two screens: src/app/index.tsx and{' '} - src/app/explore.tsx - - - The layout file in src/app/_layout.tsx sets up - the tab navigator. - - - Learn more - - - - - - - You can open this project on Android, iOS, and the web. To open the web version, - press w in the terminal running this - project. - - - - - - - - For static images, you can use the @2x and{' '} - @3x suffixes to provide files for different - screen densities. - - - - Learn more - - - - - - This template has light and dark mode support. The{' '} - useColorScheme() hook lets you inspect what the - user's current color scheme is, and so you can adjust UI colors accordingly. - - - Learn more - - - - - - This template includes an example of an animated component. The{' '} - src/components/ui/collapsible.tsx component uses - the powerful react-native-reanimated library to - animate opening this hint. - - - - {Platform.OS === 'web' && } - - - ); -} - -const styles = StyleSheet.create({ - scrollView: { - flex: 1, - }, - contentContainer: { - flexDirection: 'row', - justifyContent: 'center', - }, - container: { - maxWidth: MaxContentWidth, - flexGrow: 1, - }, - titleContainer: { - gap: Spacing.three, - alignItems: 'center', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.six, - }, - centerText: { - textAlign: 'center', - }, - pressed: { - opacity: 0.7, - }, - linkButton: { - flexDirection: 'row', - paddingHorizontal: Spacing.four, - paddingVertical: Spacing.two, - borderRadius: Spacing.five, - justifyContent: 'center', - gap: Spacing.one, - alignItems: 'center', - }, - sectionsWrapper: { - gap: Spacing.five, - paddingHorizontal: Spacing.four, - paddingTop: Spacing.three, - }, - collapsibleContent: { - alignItems: 'center', - }, - imageTutorial: { - width: '100%', - aspectRatio: 296 / 171, - borderRadius: Spacing.three, - marginTop: Spacing.two, - }, - imageReact: { - width: 100, - height: 100, - alignSelf: 'center', - }, -}); diff --git a/src/app/index.tsx b/src/app/index.tsx index 8ec3e6f..4bef814 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,98 +1,295 @@ -import * as Device from 'expo-device'; -import { Platform, StyleSheet } from 'react-native'; -import { SafeAreaView } from 'react-native-safe-area-context'; +import { mobileDramaFeed } from '@infiplot/core'; +import type { MobileDrama } from '@infiplot/types'; +import { SymbolView } from 'expo-symbols'; +import type { ComponentProps } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { + Dimensions, + FlatList, + Pressable, + StyleSheet, + Text, + View, + type NativeScrollEvent, + type NativeSyntheticEvent, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; -import { AnimatedIcon } from '@/components/animated-icon'; -import { HintRow } from '@/components/hint-row'; -import { ThemedText } from '@/components/themed-text'; -import { ThemedView } from '@/components/themed-view'; -import { WebBadge } from '@/components/web-badge'; -import { BottomTabInset, MaxContentWidth, Spacing } from '@/constants/theme'; +import { BottomTabInset } from '@/constants/theme'; -function getDevMenuHint() { - if (Platform.OS === 'web') { - return use browser devtools; +type SymbolName = ComponentProps['name']; + +export default function FeedScreen() { + const { height } = Dimensions.get('window'); + const insets = useSafeAreaInsets(); + const pageHeight = height; + const [activeIndex, setActiveIndex] = useState(0); + const listRef = useRef>(null); + + const snapOffsets = useMemo( + () => mobileDramaFeed.map((_, index) => index * pageHeight), + [pageHeight], + ); + + function handleScrollEnd(event: NativeSyntheticEvent) { + const nextIndex = Math.round(event.nativeEvent.contentOffset.y / pageHeight); + setActiveIndex(Math.max(0, Math.min(mobileDramaFeed.length - 1, nextIndex))); } - if (Device.isDevice) { - return ( - - shake device or press m in terminal - - ); - } - const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d'; + return ( - - press {shortcut} - + + item.id} + pagingEnabled + snapToOffsets={snapOffsets} + decelerationRate="fast" + showsVerticalScrollIndicator={false} + onMomentumScrollEnd={handleScrollEnd} + renderItem={({ item, index }) => ( + + )} + /> + ); } -export default function HomeScreen() { +function DramaPage({ + drama, + active, + height, + bottomInset, +}: { + drama: MobileDrama; + active: boolean; + height: number; + bottomInset: number; +}) { return ( - - - - - - Welcome to Expo - - + + + + {drama.title} + {drama.episode} + - - get started - + + + InfiPlot + + + 刷剧 + + - - src/app/index.tsx} - /> - - npm run reset-project} - /> - + + + + + - {Platform.OS === 'web' && } + + @{drama.creator} + {drama.title} + {drama.hook} + + {drama.tags.map((tag) => ( + + #{tag} + + ))} + + + [styles.primaryButton, pressed && styles.pressed]}> + + 继续看 + + {drama.episode} + + - + + ); +} + +function ActionButton({ name, label }: { name: SymbolName; label: string }) { + return ( + [styles.actionButton, pressed && styles.pressed]}> + + + + {label} + ); } const styles = StyleSheet.create({ - container: { + root: { flex: 1, + backgroundColor: '#000', + }, + page: { + overflow: 'hidden', + }, + posterGlow: { + position: 'absolute', + width: 520, + height: 520, + borderRadius: 260, + top: '14%', + left: -120, + opacity: 0.5, + transform: [{ rotate: '-18deg' }], + }, + posterOrb: { + position: 'absolute', + top: '18%', + alignSelf: 'center', + width: '76%', + aspectRatio: 0.72, + borderWidth: 1, + borderRadius: 28, justifyContent: 'center', - flexDirection: 'row', - }, - safeArea: { - flex: 1, - paddingHorizontal: Spacing.four, alignItems: 'center', - gap: Spacing.three, - paddingBottom: BottomTabInset + Spacing.three, - maxWidth: MaxContentWidth, + padding: 24, + backgroundColor: 'rgba(255,255,255,0.08)', }, - heroSection: { - alignItems: 'center', - justifyContent: 'center', - flex: 1, - paddingHorizontal: Spacing.four, - gap: Spacing.four, - }, - title: { + posterTitle: { + fontSize: 42, + lineHeight: 48, + fontWeight: '800', textAlign: 'center', }, - code: { - textTransform: 'uppercase', + posterSubtitle: { + color: 'rgba(255,255,255,0.72)', + marginTop: 14, + fontSize: 13, + fontWeight: '700', }, - stepContainer: { - gap: Spacing.three, - alignSelf: 'stretch', - paddingHorizontal: Spacing.three, - paddingVertical: Spacing.four, - borderRadius: Spacing.four, + safe: { + flex: 1, + }, + topBar: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 18, + paddingTop: 6, + }, + brand: { + color: '#fff', + fontSize: 20, + fontWeight: '900', + }, + livePill: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 99, + backgroundColor: 'rgba(0,0,0,0.32)', + }, + liveDot: { + width: 7, + height: 7, + borderRadius: 7, + }, + liveText: { + color: '#fff', + fontSize: 12, + fontWeight: '800', + }, + sideRail: { + position: 'absolute', + right: 14, + gap: 18, + alignItems: 'center', + }, + actionButton: { + alignItems: 'center', + gap: 6, + }, + actionIcon: { + width: 48, + height: 48, + borderRadius: 24, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.16)', + }, + actionLabel: { + color: '#fff', + fontSize: 11, + fontWeight: '800', + }, + infoPanel: { + marginTop: 'auto', + paddingLeft: 18, + paddingRight: 82, + gap: 10, + }, + creator: { + color: '#fff', + fontSize: 15, + fontWeight: '900', + }, + title: { + color: '#fff', + fontSize: 30, + lineHeight: 36, + fontWeight: '900', + }, + hook: { + color: 'rgba(255,255,255,0.88)', + fontSize: 15, + lineHeight: 22, + fontWeight: '600', + }, + tags: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + }, + tag: { + color: '#fff', + fontSize: 13, + fontWeight: '800', + }, + playRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + marginTop: 4, + }, + primaryButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 6, + paddingHorizontal: 18, + height: 42, + borderRadius: 21, + backgroundColor: '#fff', + }, + primaryButtonText: { + color: '#101010', + fontSize: 14, + fontWeight: '900', + }, + episode: { + color: 'rgba(255,255,255,0.78)', + fontSize: 12, + fontWeight: '700', + flexShrink: 1, + }, + pressed: { + opacity: 0.75, }, }); diff --git a/src/app/profile.tsx b/src/app/profile.tsx new file mode 100644 index 0000000..bb8006d --- /dev/null +++ b/src/app/profile.tsx @@ -0,0 +1,676 @@ +import type { ModelConfig, ModelConfigMap, ModelRole } from '@infiplot/types'; +import { SymbolView } from 'expo-symbols'; +import type { ComponentProps } from 'react'; +import { useEffect, useMemo, useState } from 'react'; +import { + Pressable, + ScrollView, + StyleSheet, + Switch, + Text, + TextInput, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { BottomTabInset } from '@/constants/theme'; +import { + clearModelConfig, + defaultModelConfig, + readModelConfig, + writeModelConfig, +} from '@/lib/model-config'; + +type SymbolName = ComponentProps['name']; + +const modelGroups: { + id: ModelRole; + title: string; + hint: string; + accent: string; +}[] = [ + { id: 'text', title: '文本模型', hint: '剧情、分镜、选择分支', accent: '#ff4d6d' }, + { id: 'image', title: '图像模型', hint: '角色、场景、封面生成', accent: '#b8ff5d' }, + { id: 'vision', title: '视觉理解', hint: '风格图解析、画面标注', accent: '#78d7ff' }, + { id: 'tts', title: '语音模型', hint: '对白配音、旁白', accent: '#ffd166' }, +]; + +const roleLabels: Record = { + text: '文本', + image: '图像', + vision: '视觉', + tts: '语音', +}; + +export default function ProfileScreen() { + const [syncEnabled, setSyncEnabled] = useState(true); + const [showKeys, setShowKeys] = useState(false); + const [signedIn, setSignedIn] = useState(false); + const [email, setEmail] = useState(''); + const [selectedRole, setSelectedRole] = useState('text'); + const [configs, setConfigs] = useState(defaultModelConfig); + const [savedConfigs, setSavedConfigs] = useState(defaultModelConfig); + const [status, setStatus] = useState<'loading' | 'saved' | 'dirty'>('loading'); + + useEffect(() => { + let mounted = true; + + readModelConfig().then((stored) => { + if (!mounted) return; + setConfigs(stored); + setSavedConfigs(stored); + setStatus('saved'); + }); + + return () => { + mounted = false; + }; + }, []); + + const selectedGroup = modelGroups.find((group) => group.id === selectedRole) ?? modelGroups[0]; + const selectedConfig = configs[selectedRole]; + + const configuredCount = useMemo( + () => Object.values(configs).filter((config) => config.apiKey.trim().length > 0).length, + [configs], + ); + + const dirty = useMemo( + () => JSON.stringify(configs) !== JSON.stringify(savedConfigs), + [configs, savedConfigs], + ); + + function updateConfig(field: keyof ModelConfig, value: string) { + setConfigs((current) => ({ + ...current, + [selectedRole]: { + ...current[selectedRole], + [field]: value, + }, + })); + setStatus('dirty'); + } + + async function handleSave() { + const saved = await writeModelConfig(configs); + setConfigs(saved); + setSavedConfigs(saved); + setStatus('saved'); + } + + async function handleReset() { + const reset = await clearModelConfig(); + setConfigs(reset); + setSavedConfigs(reset); + setSelectedRole('text'); + setStatus('saved'); + } + + function handleAuthAction() { + if (signedIn) { + setSignedIn(false); + setEmail(''); + return; + } + + if (email.trim().length > 3) { + setSignedIn(true); + } + } + + return ( + + + + + IP + + + {signedIn ? 'InfiPlot 创作者' : '未登录创作者'} + + {signedIn ? email.trim() : '登录后同步我的剧情、草稿和模型配置。'} + + + [styles.loginButton, pressed && styles.pressed]}> + {signedIn ? '退出' : '登录'} + + + + {!signedIn && ( + + + [ + styles.loginSubmit, + email.trim().length <= 3 && styles.disabledButton, + pressed && email.trim().length > 3 && styles.pressed, + ]}> + 继续 + + + )} + + + + + + + + + + 模型 API + 对应 web 端文字、图片、视觉和 TTS 配置。 + + setShowKeys((value) => !value)} + style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}> + + + + + + {modelGroups.map((group) => { + const selected = group.id === selectedRole; + const configured = configs[group.id].apiKey.trim().length > 0; + + return ( + setSelectedRole(group.id)} + style={({ pressed }) => [ + styles.roleTab, + selected && styles.selectedRoleTab, + pressed && styles.pressed, + ]}> + + + {roleLabels[group.id]} + + + ); + })} + + + + + + {selectedGroup.title} + {selectedGroup.hint} + + + + {status === 'loading' ? '读取中' : dirty ? '未保存' : '已保存'} + + + + + updateConfig('provider', value)} + placeholder="openai / claude / gemini / stepfun" + /> + updateConfig('baseUrl', value)} + placeholder="https://api.example.com/v1" + /> + updateConfig('apiKey', value)} + secureTextEntry={!showKeys} + placeholder="sk-..." + /> + updateConfig('model', value)} + placeholder="model name" + /> + + + [styles.secondaryButton, pressed && styles.pressed]}> + 重置 + + [ + styles.saveButton, + !dirty && styles.disabledButton, + pressed && dirty && styles.pressed, + ]}> + + 保存配置 + + + + + + + 云端同步 + 登录后自动同步我的剧情和配置。 + + + + + + + + + + + + ); +} + +function Stat({ value, label }: { value: string; label: string }) { + return ( + + {value} + {label} + + ); +} + +function InputLine({ + label, + value, + onChangeText, + secureTextEntry, + placeholder, +}: { + label: string; + value: string; + onChangeText: (value: string) => void; + secureTextEntry?: boolean; + placeholder: string; +}) { + return ( + + {label} + + + ); +} + +function MenuItem({ name, label }: { name: SymbolName; label: string }) { + return ( + [styles.menuItem, pressed && styles.pressed]}> + + + + {label} + + + ); +} + +const styles = StyleSheet.create({ + safe: { + flex: 1, + backgroundColor: '#09090b', + }, + content: { + padding: 18, + paddingBottom: BottomTabInset + 34, + gap: 18, + }, + profileCard: { + flexDirection: 'row', + alignItems: 'center', + gap: 12, + borderRadius: 8, + padding: 14, + backgroundColor: 'rgba(255,255,255,0.08)', + }, + avatar: { + width: 54, + height: 54, + borderRadius: 27, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#ff4d6d', + }, + avatarText: { + color: '#fff', + fontSize: 18, + fontWeight: '900', + }, + profileCopy: { + flex: 1, + gap: 3, + }, + name: { + color: '#fff', + fontSize: 18, + fontWeight: '900', + }, + subline: { + color: 'rgba(255,255,255,0.58)', + fontSize: 12, + lineHeight: 17, + fontWeight: '700', + }, + loginButton: { + height: 36, + paddingHorizontal: 16, + borderRadius: 18, + justifyContent: 'center', + backgroundColor: '#fff', + }, + loginText: { + color: '#09090b', + fontSize: 13, + fontWeight: '900', + }, + loginPanel: { + flexDirection: 'row', + gap: 10, + borderRadius: 8, + padding: 12, + backgroundColor: 'rgba(255,255,255,0.06)', + }, + loginInput: { + flex: 1, + minHeight: 42, + borderRadius: 8, + paddingHorizontal: 12, + color: '#fff', + fontSize: 13, + fontWeight: '700', + backgroundColor: 'rgba(0,0,0,0.22)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + loginSubmit: { + minWidth: 70, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: '#fff', + }, + loginSubmitText: { + color: '#09090b', + fontSize: 13, + fontWeight: '900', + }, + stats: { + flexDirection: 'row', + borderRadius: 8, + paddingVertical: 14, + backgroundColor: 'rgba(255,255,255,0.06)', + }, + stat: { + flex: 1, + alignItems: 'center', + gap: 4, + }, + statValue: { + color: '#fff', + fontSize: 20, + fontWeight: '900', + }, + statLabel: { + color: 'rgba(255,255,255,0.54)', + fontSize: 12, + fontWeight: '800', + }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 16, + }, + sectionTitle: { + color: '#fff', + fontSize: 23, + fontWeight: '900', + }, + sectionHint: { + color: 'rgba(255,255,255,0.56)', + fontSize: 12, + lineHeight: 18, + fontWeight: '700', + marginTop: 4, + }, + iconButton: { + width: 42, + height: 42, + borderRadius: 21, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + roleTabs: { + flexDirection: 'row', + gap: 8, + }, + roleTab: { + flex: 1, + minHeight: 42, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + gap: 6, + backgroundColor: 'rgba(255,255,255,0.07)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + selectedRoleTab: { + backgroundColor: 'rgba(255,255,255,0.14)', + borderColor: 'rgba(255,255,255,0.2)', + }, + roleDot: { + width: 7, + height: 7, + borderRadius: 7, + }, + roleText: { + color: 'rgba(255,255,255,0.58)', + fontSize: 12, + fontWeight: '900', + }, + selectedRoleText: { + color: '#fff', + }, + modelEditor: { + gap: 12, + borderRadius: 8, + padding: 14, + backgroundColor: 'rgba(255,255,255,0.075)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + modelTop: { + flexDirection: 'row', + justifyContent: 'space-between', + gap: 12, + }, + modelTitle: { + color: '#fff', + fontSize: 17, + fontWeight: '900', + }, + modelHint: { + color: 'rgba(255,255,255,0.52)', + fontSize: 12, + fontWeight: '700', + marginTop: 3, + }, + statusPill: { + height: 26, + justifyContent: 'center', + borderRadius: 13, + paddingHorizontal: 10, + backgroundColor: 'rgba(184,255,93,0.16)', + }, + dirtyPill: { + backgroundColor: 'rgba(255,209,102,0.16)', + }, + statusText: { + color: '#b8ff5d', + fontSize: 11, + fontWeight: '900', + }, + dirtyText: { + color: '#ffd166', + }, + inputLine: { + gap: 6, + }, + inputLabel: { + color: 'rgba(255,255,255,0.5)', + fontSize: 11, + fontWeight: '900', + }, + input: { + minHeight: 42, + borderRadius: 8, + paddingHorizontal: 12, + color: '#fff', + fontSize: 13, + fontWeight: '700', + backgroundColor: 'rgba(0,0,0,0.22)', + borderWidth: 1, + borderColor: 'rgba(255,255,255,0.08)', + }, + actionRow: { + flexDirection: 'row', + gap: 10, + marginTop: 2, + }, + secondaryButton: { + height: 44, + borderRadius: 22, + paddingHorizontal: 18, + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + secondaryText: { + color: '#fff', + fontSize: 14, + fontWeight: '900', + }, + saveButton: { + flex: 1, + height: 44, + borderRadius: 22, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: 7, + backgroundColor: '#fff', + }, + disabledButton: { + opacity: 0.42, + }, + saveText: { + color: '#09090b', + fontSize: 14, + fontWeight: '900', + }, + settingRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + gap: 18, + borderRadius: 8, + padding: 14, + backgroundColor: 'rgba(255,255,255,0.07)', + }, + settingTextBlock: { + flex: 1, + gap: 3, + }, + settingTitle: { + color: '#fff', + fontSize: 15, + fontWeight: '900', + }, + settingHint: { + color: 'rgba(255,255,255,0.52)', + fontSize: 12, + fontWeight: '700', + }, + menuList: { + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(255,255,255,0.07)', + }, + menuItem: { + minHeight: 56, + flexDirection: 'row', + alignItems: 'center', + gap: 12, + paddingHorizontal: 14, + borderBottomWidth: StyleSheet.hairlineWidth, + borderBottomColor: 'rgba(255,255,255,0.08)', + }, + menuIcon: { + width: 34, + height: 34, + borderRadius: 17, + alignItems: 'center', + justifyContent: 'center', + backgroundColor: 'rgba(255,255,255,0.1)', + }, + menuLabel: { + flex: 1, + color: '#fff', + fontSize: 14, + fontWeight: '800', + }, + pressed: { + opacity: 0.76, + }, +}); diff --git a/src/components/app-tabs.tsx b/src/components/app-tabs.tsx index 80719bc..02a2f9c 100644 --- a/src/components/app-tabs.tsx +++ b/src/components/app-tabs.tsx @@ -1,30 +1,36 @@ import { NativeTabs } from 'expo-router/unstable-native-tabs'; -import { useColorScheme } from 'react-native'; - -import { Colors } from '@/constants/theme'; export default function AppTabs() { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - return ( + backgroundColor="#09090b" + iconColor={{ default: 'rgba(255,255,255,0.54)', selected: '#ffffff' }} + indicatorColor="rgba(255,255,255,0.12)" + labelStyle={{ + default: { color: 'rgba(255,255,255,0.54)', fontSize: 11, fontWeight: '800' }, + selected: { color: '#ffffff', fontSize: 11, fontWeight: '900' }, + }}> - Home + 刷剧 - - Explore + + 创作 + + + + 我的 + diff --git a/src/components/app-tabs.web.tsx b/src/components/app-tabs.web.tsx index ca2787d..4867893 100644 --- a/src/components/app-tabs.web.tsx +++ b/src/components/app-tabs.web.tsx @@ -1,115 +1,112 @@ import { - Tabs, TabList, - TabTrigger, TabSlot, + TabTrigger, TabTriggerSlotProps, - TabListProps, + Tabs, } from 'expo-router/ui'; import { SymbolView } from 'expo-symbols'; -import { Pressable, useColorScheme, View, StyleSheet } from 'react-native'; +import type { ComponentProps } from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import type { Href } from 'expo-router'; -import { ExternalLink } from './external-link'; -import { ThemedText } from './themed-text'; -import { ThemedView } from './themed-view'; +type SymbolName = ComponentProps['name']; -import { Colors, MaxContentWidth, Spacing } from '@/constants/theme'; +const tabs = [ + { + name: 'feed', + href: '/' as Href, + label: '刷剧', + icon: { ios: 'play.rectangle.fill', android: 'slideshow', web: 'slideshow' }, + }, + { + name: 'create', + href: '/create' as Href, + label: '创作', + icon: { ios: 'wand.and.stars', android: 'auto_fix_high', web: 'auto_fix_high' }, + }, + { + name: 'profile', + href: '/profile' as Href, + label: '我的', + icon: { ios: 'person.crop.circle.fill', android: 'person', web: 'person' }, + }, +] satisfies { + name: string; + href: Href; + label: string; + icon: SymbolName; +}[]; export default function AppTabs() { return ( - + - - - Home - - - Explore - - + + {tabs.map((tab) => ( + + {tab.label} + + ))} + ); } -export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) { +function TabButton({ + children, + icon, + isFocused, + ...props +}: TabTriggerSlotProps & { icon: SymbolName }) { return ( - pressed && styles.pressed}> - - - {children} - - + [styles.tabButton, isFocused && styles.activeTab, pressed && styles.pressed]}> + + {children} ); } -export function CustomTabList(props: TabListProps) { - const scheme = useColorScheme(); - const colors = Colors[scheme === 'unspecified' ? 'light' : scheme]; - - return ( - - - - Expo Starter - - - {props.children} - - - - Docs - - - - - - ); -} - const styles = StyleSheet.create({ - tabListContainer: { + slot: { + height: '100%', + }, + tabList: { position: 'absolute', - width: '100%', - padding: Spacing.three, + left: 0, + right: 0, + bottom: 0, + height: 74, + flexDirection: 'row', + justifyContent: 'space-around', + alignItems: 'center', + backgroundColor: '#09090b', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: 'rgba(255,255,255,0.12)', + }, + tabButton: { + minWidth: 86, + height: 52, + borderRadius: 8, + alignItems: 'center', justifyContent: 'center', - alignItems: 'center', - flexDirection: 'row', + gap: 4, }, - innerContainer: { - paddingVertical: Spacing.two, - paddingHorizontal: Spacing.five, - borderRadius: Spacing.five, - flexDirection: 'row', - alignItems: 'center', - flexGrow: 1, - gap: Spacing.two, - maxWidth: MaxContentWidth, + activeTab: { + backgroundColor: 'rgba(255,255,255,0.1)', }, - brandText: { - marginRight: 'auto', + tabText: { + color: 'rgba(255,255,255,0.5)', + fontSize: 12, + fontWeight: '800', + }, + activeText: { + color: '#fff', + fontWeight: '900', }, pressed: { - opacity: 0.7, - }, - tabButtonView: { - paddingVertical: Spacing.one, - paddingHorizontal: Spacing.three, - borderRadius: Spacing.three, - }, - externalPressable: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: Spacing.one, - marginLeft: Spacing.three, + opacity: 0.72, }, }); diff --git a/src/lib/model-config.ts b/src/lib/model-config.ts new file mode 100644 index 0000000..d8a17ac --- /dev/null +++ b/src/lib/model-config.ts @@ -0,0 +1,56 @@ +import { defaultModelConfig, normalizeModelConfig } from '@infiplot/core'; +import type { ModelConfigMap } from '@infiplot/types'; +import { Platform } from 'react-native'; + +const STORAGE_KEY = 'infiplot:mobile:model-config'; + +export { defaultModelConfig }; +export type { ModelConfigMap }; + +let memoryStore: string | null = null; + +function getWebStorage() { + if (Platform.OS !== 'web' || typeof window === 'undefined') return null; + return window.localStorage; +} + +export async function readModelConfig(): Promise { + try { + const storage = getWebStorage(); + const raw = storage ? storage.getItem(STORAGE_KEY) : memoryStore; + if (!raw) return defaultModelConfig; + return normalizeModelConfig(JSON.parse(raw)); + } catch { + return defaultModelConfig; + } +} + +export async function writeModelConfig(config: ModelConfigMap): Promise { + const normalized = normalizeModelConfig(config); + const serialized = JSON.stringify(normalized); + + try { + const storage = getWebStorage(); + if (storage) { + storage.setItem(STORAGE_KEY, serialized); + } else { + memoryStore = serialized; + } + } catch { + memoryStore = serialized; + } + + return normalized; +} + +export async function clearModelConfig(): Promise { + try { + const storage = getWebStorage(); + if (storage) storage.removeItem(STORAGE_KEY); + } catch { + // Storage can be unavailable in private sessions. + } + + memoryStore = null; + return defaultModelConfig; +} diff --git a/tsconfig.json b/tsconfig.json index 2e9a669..9295d06 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,12 @@ ], "@/assets/*": [ "./assets/*" + ], + "@infiplot/core": [ + "../packages/core/src/index.ts" + ], + "@infiplot/types": [ + "../packages/types/src/index.ts" ] } },