Compare commits
4 Commits
ed037160b0
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 65cce4868d | |||
| 53ed4a7ca5 | |||
| c750bd4c94 | |||
| c00a8c6ff6 |
@@ -0,0 +1,7 @@
|
||||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
|
||||
return {
|
||||
presets: [["babel-preset-expo", { jsxImportSource: "nativewind" }], "nativewind/babel"],
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require("eslint/config");
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,21 @@
|
||||
const path = require("path");
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withNativeWind } = require("nativewind/metro");
|
||||
|
||||
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 = withNativeWind(config, { input: "./src/global.css" });
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
/// <reference types="nativewind/types" />
|
||||
|
||||
// NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind.
|
||||
+9
-1
@@ -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",
|
||||
@@ -17,18 +19,24 @@
|
||||
"expo-symbols": "~56.0.6",
|
||||
"expo-system-ui": "~56.0.5",
|
||||
"expo-web-browser": "~56.0.5",
|
||||
"nativewind": "^4.2.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.85.3",
|
||||
"react-native-css-interop": "^0.2.6",
|
||||
"react-native-gesture-handler": "~2.31.1",
|
||||
"react-native-reanimated": "4.3.1",
|
||||
"react-native-safe-area-context": "~5.7.0",
|
||||
"react-native-screens": "4.25.2",
|
||||
"react-native-web": "~0.21.0",
|
||||
"react-native-worklets": "0.8.3"
|
||||
"react-native-worklets": "0.8.3",
|
||||
"tailwindcss": "^3.4.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "~19.2.2",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-expo": "~56.0.4",
|
||||
"prettier-plugin-tailwindcss": "^0.8.0",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DarkTheme, DefaultTheme, ThemeProvider } from 'expo-router';
|
||||
import { useColorScheme } from 'react-native';
|
||||
|
||||
import '@/global.css';
|
||||
import { AnimatedSplashOverlay } from '@/components/animated-icon';
|
||||
import AppTabs from '@/components/app-tabs';
|
||||
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
import { creationOptions, defaultStoryDraft } from "@infiplot/core";
|
||||
import { SymbolView } from "expo-symbols";
|
||||
import { useState } from "react";
|
||||
import { Pressable, ScrollView, 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 (
|
||||
<SafeAreaView className="flex-1 bg-[#09090b]">
|
||||
<ScrollView
|
||||
className="flex-1"
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentContainerStyle={{ paddingBottom: BottomTabInset + 112 }}
|
||||
>
|
||||
<View className="gap-[18px] p-[18px]">
|
||||
<View className="flex-row items-start justify-between gap-[18px] pt-2">
|
||||
<View>
|
||||
<Text className="mb-2 text-xs font-black text-[#ff4d6d]">
|
||||
AI 剧情创作
|
||||
</Text>
|
||||
<Text className="max-w-[300px] text-[30px] font-black leading-9 text-white">
|
||||
写下两段文字,生成可刷的短剧。
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-[46px] w-[46px] items-center justify-center rounded-full bg-white/10">
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: "sparkles",
|
||||
android: "auto_awesome",
|
||||
web: "auto_awesome",
|
||||
}}
|
||||
size={24}
|
||||
tintColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="gap-2.5">
|
||||
<Text className="text-[13px] font-black text-white/70">
|
||||
题材模板
|
||||
</Text>
|
||||
<ChipRow
|
||||
options={creationOptions.templates}
|
||||
value={template}
|
||||
onChange={setTemplate}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="gap-2.5">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-[13px] font-black text-white/70">
|
||||
世界设定
|
||||
</Text>
|
||||
<Text className="text-xs font-extrabold text-white/40">
|
||||
{world.length}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={world}
|
||||
onChangeText={setWorld}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="主角、世界、冲突、第一幕钩子..."
|
||||
placeholderTextColor="rgba(255,255,255,0.36)"
|
||||
className="min-h-[158px] rounded-lg border border-white/10 bg-white/10 p-3.5 text-base font-semibold leading-6 text-white"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="gap-2.5">
|
||||
<View className="flex-row items-center justify-between">
|
||||
<Text className="text-[13px] font-black text-white/70">
|
||||
视觉风格
|
||||
</Text>
|
||||
<Text className="text-xs font-extrabold text-white/40">
|
||||
{style.length}
|
||||
</Text>
|
||||
</View>
|
||||
<TextInput
|
||||
value={style}
|
||||
onChangeText={setStyle}
|
||||
multiline
|
||||
textAlignVertical="top"
|
||||
placeholder="镜头、色彩、材质、画风、氛围..."
|
||||
placeholderTextColor="rgba(255,255,255,0.36)"
|
||||
className="min-h-[116px] rounded-lg border border-white/10 bg-white/10 p-3.5 text-base font-semibold leading-6 text-white"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-3">
|
||||
<View className="flex-1 gap-3 rounded-lg bg-white/[0.07] p-3">
|
||||
<Text className="text-[13px] font-black text-white/70">
|
||||
画面比例
|
||||
</Text>
|
||||
<ChipRow
|
||||
compact
|
||||
options={creationOptions.ratios}
|
||||
value={ratio}
|
||||
onChange={setRatio}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1 gap-3 rounded-lg bg-white/[0.07] p-3">
|
||||
<Text className="text-[13px] font-black text-white/70">
|
||||
叙事节奏
|
||||
</Text>
|
||||
<ChipRow
|
||||
compact
|
||||
options={creationOptions.rhythms}
|
||||
value={rhythm}
|
||||
onChange={setRhythm}
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="gap-3 rounded-lg bg-white/[0.07] p-3.5">
|
||||
<Text className="text-[15px] font-black text-white">生成流程</Text>
|
||||
<View className="flex-row flex-wrap gap-2.5">
|
||||
{["故事圣经", "分镜脚本", "角色视觉", "首集预览"].map(
|
||||
(step, index) => (
|
||||
<View
|
||||
key={step}
|
||||
className="min-w-[46%] flex-row items-center gap-2"
|
||||
>
|
||||
<Text className="h-[22px] w-[22px] overflow-hidden rounded-full bg-[#b8ff5d] text-center text-xs font-black leading-[22px] text-[#09090b]">
|
||||
{index + 1}
|
||||
</Text>
|
||||
<Text className="text-[13px] font-extrabold text-white/80">
|
||||
{step}
|
||||
</Text>
|
||||
</View>
|
||||
),
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
|
||||
<View
|
||||
className="absolute bottom-0 left-0 right-0 bg-[#09090b]/95 px-[18px] pt-3"
|
||||
style={{ paddingBottom: BottomTabInset + 14 }}
|
||||
>
|
||||
<Pressable
|
||||
disabled={!ready}
|
||||
className={`h-[52px] flex-row items-center justify-center gap-2 rounded-full bg-white ${!ready ? "opacity-40" : ""}`}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed && ready ? 0.76 : undefined,
|
||||
})}
|
||||
>
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: "wand.and.stars",
|
||||
android: "auto_fix_high",
|
||||
web: "auto_fix_high",
|
||||
}}
|
||||
size={18}
|
||||
tintColor="#050505"
|
||||
/>
|
||||
<Text className="text-base font-black text-[#050505]">
|
||||
{ready ? "生成第一集" : "继续补充设定"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function ChipRow({
|
||||
options,
|
||||
value,
|
||||
onChange,
|
||||
compact,
|
||||
}: {
|
||||
options: readonly string[];
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<View className={`flex-row flex-wrap ${compact ? "gap-2" : "gap-2.5"}`}>
|
||||
{options.map((option) => {
|
||||
const selected = option === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={option}
|
||||
onPress={() => onChange(option)}
|
||||
className={`items-center justify-center rounded-full border ${
|
||||
compact ? "min-h-8 px-2.5" : "min-h-[38px] px-3.5"
|
||||
} ${selected ? "border-white bg-white" : "border-white/10 bg-white/10"}`}
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.76 : undefined })}
|
||||
>
|
||||
<Text
|
||||
className={`text-[13px] font-extrabold ${selected ? "text-[#09090b]" : "text-white"}`}
|
||||
>
|
||||
{option}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<ScrollView
|
||||
style={[styles.scrollView, { backgroundColor: theme.background }]}
|
||||
contentInset={insets}
|
||||
contentContainerStyle={[styles.contentContainer, contentPlatformStyle]}>
|
||||
<ThemedView style={styles.container}>
|
||||
<ThemedView style={styles.titleContainer}>
|
||||
<ThemedText type="subtitle">Explore</ThemedText>
|
||||
<ThemedText style={styles.centerText} themeColor="textSecondary">
|
||||
This starter app includes example{'\n'}code to help you get started.
|
||||
</ThemedText>
|
||||
|
||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||
<Pressable style={({ pressed }) => pressed && styles.pressed}>
|
||||
<ThemedView type="backgroundElement" style={styles.linkButton}>
|
||||
<ThemedText type="link">Expo documentation</ThemedText>
|
||||
<SymbolView
|
||||
tintColor={theme.text}
|
||||
name={{ ios: 'arrow.up.right.square', android: 'link', web: 'link' }}
|
||||
size={12}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
</ExternalLink>
|
||||
</ThemedView>
|
||||
|
||||
<ThemedView style={styles.sectionsWrapper}>
|
||||
<Collapsible title="File-based routing">
|
||||
<ThemedText type="small">
|
||||
This app has two screens: <ThemedText type="code">src/app/index.tsx</ThemedText> and{' '}
|
||||
<ThemedText type="code">src/app/explore.tsx</ThemedText>
|
||||
</ThemedText>
|
||||
<ThemedText type="small">
|
||||
The layout file in <ThemedText type="code">src/app/_layout.tsx</ThemedText> sets up
|
||||
the tab navigator.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/router/introduction">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Android, iOS, and web support">
|
||||
<ThemedView type="backgroundElement" style={styles.collapsibleContent}>
|
||||
<ThemedText type="small">
|
||||
You can open this project on Android, iOS, and the web. To open the web version,
|
||||
press <ThemedText type="smallBold">w</ThemedText> in the terminal running this
|
||||
project.
|
||||
</ThemedText>
|
||||
<Image
|
||||
source={require('@/assets/images/tutorial-web.png')}
|
||||
style={styles.imageTutorial}
|
||||
/>
|
||||
</ThemedView>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Images">
|
||||
<ThemedText type="small">
|
||||
For static images, you can use the <ThemedText type="code">@2x</ThemedText> and{' '}
|
||||
<ThemedText type="code">@3x</ThemedText> suffixes to provide files for different
|
||||
screen densities.
|
||||
</ThemedText>
|
||||
<Image source={require('@/assets/images/react-logo.png')} style={styles.imageReact} />
|
||||
<ExternalLink href="https://reactnative.dev/docs/images">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Light and dark mode components">
|
||||
<ThemedText type="small">
|
||||
This template has light and dark mode support. The{' '}
|
||||
<ThemedText type="code">useColorScheme()</ThemedText> hook lets you inspect what the
|
||||
user's current color scheme is, and so you can adjust UI colors accordingly.
|
||||
</ThemedText>
|
||||
<ExternalLink href="https://docs.expo.dev/develop/user-interface/color-themes/">
|
||||
<ThemedText type="linkPrimary">Learn more</ThemedText>
|
||||
</ExternalLink>
|
||||
</Collapsible>
|
||||
|
||||
<Collapsible title="Animations">
|
||||
<ThemedText type="small">
|
||||
This template includes an example of an animated component. The{' '}
|
||||
<ThemedText type="code">src/components/ui/collapsible.tsx</ThemedText> component uses
|
||||
the powerful <ThemedText type="code">react-native-reanimated</ThemedText> library to
|
||||
animate opening this hint.
|
||||
</ThemedText>
|
||||
</Collapsible>
|
||||
</ThemedView>
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
</ThemedView>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
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',
|
||||
},
|
||||
});
|
||||
+185
-80
@@ -1,98 +1,203 @@
|
||||
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,
|
||||
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 <ThemedText type="small">use browser devtools</ThemedText>;
|
||||
}
|
||||
if (Device.isDevice) {
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
shake device or press <ThemedText type="code">m</ThemedText> in terminal
|
||||
</ThemedText>
|
||||
type SymbolName = ComponentProps<typeof SymbolView>["name"];
|
||||
|
||||
export default function FeedScreen() {
|
||||
const { height } = Dimensions.get("window");
|
||||
const insets = useSafeAreaInsets();
|
||||
const pageHeight = height;
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const listRef = useRef<FlatList<MobileDrama>>(null);
|
||||
|
||||
const snapOffsets = useMemo(
|
||||
() => mobileDramaFeed.map((_, index) => index * pageHeight),
|
||||
[pageHeight],
|
||||
);
|
||||
|
||||
function handleScrollEnd(event: NativeSyntheticEvent<NativeScrollEvent>) {
|
||||
const nextIndex = Math.round(
|
||||
event.nativeEvent.contentOffset.y / pageHeight,
|
||||
);
|
||||
setActiveIndex(
|
||||
Math.max(0, Math.min(mobileDramaFeed.length - 1, nextIndex)),
|
||||
);
|
||||
}
|
||||
const shortcut = Platform.OS === 'android' ? 'cmd+m (or ctrl+m)' : 'cmd+d';
|
||||
|
||||
return (
|
||||
<ThemedText type="small">
|
||||
press <ThemedText type="code">{shortcut}</ThemedText>
|
||||
</ThemedText>
|
||||
<View className="flex-1 bg-black">
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
data={mobileDramaFeed}
|
||||
keyExtractor={(item) => item.id}
|
||||
pagingEnabled
|
||||
snapToOffsets={snapOffsets}
|
||||
decelerationRate="fast"
|
||||
showsVerticalScrollIndicator={false}
|
||||
onMomentumScrollEnd={handleScrollEnd}
|
||||
renderItem={({ item, index }) => (
|
||||
<DramaPage
|
||||
drama={item}
|
||||
active={index === activeIndex}
|
||||
height={pageHeight}
|
||||
bottomInset={insets.bottom}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeScreen() {
|
||||
function DramaPage({
|
||||
drama,
|
||||
active,
|
||||
height,
|
||||
bottomInset,
|
||||
}: {
|
||||
drama: MobileDrama;
|
||||
active: boolean;
|
||||
height: number;
|
||||
bottomInset: number;
|
||||
}) {
|
||||
return (
|
||||
<ThemedView style={styles.container}>
|
||||
<SafeAreaView style={styles.safeArea}>
|
||||
<ThemedView style={styles.heroSection}>
|
||||
<AnimatedIcon />
|
||||
<ThemedText type="title" style={styles.title}>
|
||||
Welcome to Expo
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<View
|
||||
className="overflow-hidden"
|
||||
style={{ height, backgroundColor: drama.palette[0] }}
|
||||
>
|
||||
<View
|
||||
className="absolute left-[-120px] top-[14%] h-[520px] w-[520px] rounded-full opacity-50"
|
||||
style={{
|
||||
backgroundColor: drama.palette[1],
|
||||
transform: [{ rotate: "-18deg" }],
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
className="absolute top-[18%] w-[76%] self-center rounded-[28px] border bg-white/10 p-6"
|
||||
style={{ aspectRatio: 0.72, borderColor: drama.palette[2] }}
|
||||
>
|
||||
<Text
|
||||
className="text-center text-[42px] font-extrabold leading-[48px]"
|
||||
style={{ color: drama.palette[2] }}
|
||||
>
|
||||
{drama.title}
|
||||
</Text>
|
||||
<Text className="mt-3.5 text-[13px] font-bold text-white/70">
|
||||
{drama.episode}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<ThemedText type="code" style={styles.code}>
|
||||
get started
|
||||
</ThemedText>
|
||||
<SafeAreaView className="flex-1">
|
||||
<View className="flex-row items-center justify-between px-[18px] pt-1.5">
|
||||
<Text className="text-xl font-black text-white">InfiPlot</Text>
|
||||
<View className="flex-row items-center gap-1.5 rounded-full bg-black/30 px-3 py-[7px]">
|
||||
<View
|
||||
className="h-[7px] w-[7px] rounded-full"
|
||||
style={{ backgroundColor: active ? "#ff2d55" : "#777" }}
|
||||
/>
|
||||
<Text className="text-xs font-extrabold text-white">刷剧</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ThemedView type="backgroundElement" style={styles.stepContainer}>
|
||||
<HintRow
|
||||
title="Try editing"
|
||||
hint={<ThemedText type="code">src/app/index.tsx</ThemedText>}
|
||||
<View
|
||||
className="absolute right-3.5 items-center gap-[18px]"
|
||||
style={{ bottom: bottomInset + BottomTabInset + 86 }}
|
||||
>
|
||||
<ActionButton
|
||||
name={{ ios: "heart.fill", android: "favorite", web: "favorite" }}
|
||||
label={drama.likes}
|
||||
/>
|
||||
<HintRow title="Dev tools" hint={getDevMenuHint()} />
|
||||
<HintRow
|
||||
title="Fresh start"
|
||||
hint={<ThemedText type="code">npm run reset-project</ThemedText>}
|
||||
<ActionButton
|
||||
name={{
|
||||
ios: "bubble.right.fill",
|
||||
android: "chat_bubble",
|
||||
web: "chat_bubble",
|
||||
}}
|
||||
label={drama.comments}
|
||||
/>
|
||||
</ThemedView>
|
||||
<ActionButton
|
||||
name={{
|
||||
ios: "square.and.arrow.up",
|
||||
android: "ios_share",
|
||||
web: "ios_share",
|
||||
}}
|
||||
label="分享"
|
||||
/>
|
||||
</View>
|
||||
|
||||
{Platform.OS === 'web' && <WebBadge />}
|
||||
<View
|
||||
className="mt-auto gap-2.5 pl-[18px] pr-[82px]"
|
||||
style={{ paddingBottom: bottomInset + BottomTabInset + 20 }}
|
||||
>
|
||||
<Text className="text-[15px] font-black text-white">
|
||||
@{drama.creator}
|
||||
</Text>
|
||||
<Text className="text-[30px] font-black leading-9 text-white">
|
||||
{drama.title}
|
||||
</Text>
|
||||
<Text className="text-[15px] font-semibold leading-[22px] text-white/90">
|
||||
{drama.hook}
|
||||
</Text>
|
||||
<View className="flex-row flex-wrap gap-2">
|
||||
{drama.tags.map((tag) => (
|
||||
<Text key={tag} className="text-[13px] font-extrabold text-white">
|
||||
#{tag}
|
||||
</Text>
|
||||
))}
|
||||
</View>
|
||||
<View className="mt-1 flex-row items-center gap-3">
|
||||
<Pressable
|
||||
className="h-[42px] flex-row items-center justify-center gap-1.5 rounded-full bg-white px-[18px]"
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.75 : undefined })}
|
||||
>
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: "play.fill",
|
||||
android: "play_arrow",
|
||||
web: "play_arrow",
|
||||
}}
|
||||
size={16}
|
||||
tintColor="#101010"
|
||||
/>
|
||||
<Text className="text-sm font-black text-[#101010]">继续看</Text>
|
||||
</Pressable>
|
||||
<Text className="shrink text-xs font-bold text-white/80">
|
||||
{drama.episode}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaView>
|
||||
</ThemedView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'row',
|
||||
},
|
||||
safeArea: {
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
alignItems: 'center',
|
||||
gap: Spacing.three,
|
||||
paddingBottom: BottomTabInset + Spacing.three,
|
||||
maxWidth: MaxContentWidth,
|
||||
},
|
||||
heroSection: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flex: 1,
|
||||
paddingHorizontal: Spacing.four,
|
||||
gap: Spacing.four,
|
||||
},
|
||||
title: {
|
||||
textAlign: 'center',
|
||||
},
|
||||
code: {
|
||||
textTransform: 'uppercase',
|
||||
},
|
||||
stepContainer: {
|
||||
gap: Spacing.three,
|
||||
alignSelf: 'stretch',
|
||||
paddingHorizontal: Spacing.three,
|
||||
paddingVertical: Spacing.four,
|
||||
borderRadius: Spacing.four,
|
||||
},
|
||||
});
|
||||
function ActionButton({ name, label }: { name: SymbolName; label: string }) {
|
||||
return (
|
||||
<Pressable
|
||||
className="items-center gap-1.5"
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.75 : undefined })}
|
||||
>
|
||||
<View className="h-12 w-12 items-center justify-center rounded-full bg-white/15">
|
||||
<SymbolView name={name} size={24} tintColor="#fff" />
|
||||
</View>
|
||||
<Text className="text-[11px] font-extrabold text-white">{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
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,
|
||||
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<typeof SymbolView>["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<ModelRole, string> = {
|
||||
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<ModelRole>("text");
|
||||
const [configs, setConfigs] = useState<ModelConfigMap>(defaultModelConfig);
|
||||
const [savedConfigs, setSavedConfigs] =
|
||||
useState<ModelConfigMap>(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 (
|
||||
<SafeAreaView className="flex-1 bg-[#09090b]">
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: BottomTabInset + 34 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
<View className="gap-[18px] p-[18px]">
|
||||
<View className="flex-row items-center gap-3 rounded-lg bg-white/10 p-3.5">
|
||||
<View className="h-[54px] w-[54px] items-center justify-center rounded-full bg-[#ff4d6d]">
|
||||
<Text className="text-lg font-black text-white">IP</Text>
|
||||
</View>
|
||||
<View className="flex-1 gap-[3px]">
|
||||
<Text className="text-lg font-black text-white">
|
||||
{signedIn ? "InfiPlot 创作者" : "未登录创作者"}
|
||||
</Text>
|
||||
<Text className="text-xs font-bold leading-[17px] text-white/60">
|
||||
{signedIn
|
||||
? email.trim()
|
||||
: "登录后同步我的剧情、草稿和模型配置。"}
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={handleAuthAction}
|
||||
className="h-9 justify-center rounded-full bg-white px-4"
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.76 : undefined })}
|
||||
>
|
||||
<Text className="text-[13px] font-black text-[#09090b]">
|
||||
{signedIn ? "退出" : "登录"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{!signedIn && (
|
||||
<View className="flex-row gap-2.5 rounded-lg bg-white/[0.06] p-3">
|
||||
<TextInput
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="email-address"
|
||||
placeholder="creator@infiplot.app"
|
||||
placeholderTextColor="rgba(255,255,255,0.35)"
|
||||
className="min-h-[42px] flex-1 rounded-lg border border-white/10 bg-black/20 px-3 text-[13px] font-bold text-white"
|
||||
/>
|
||||
<Pressable
|
||||
onPress={handleAuthAction}
|
||||
disabled={email.trim().length <= 3}
|
||||
className={`h-[42px] min-w-[70px] items-center justify-center rounded-full bg-white ${
|
||||
email.trim().length <= 3 ? "opacity-40" : ""
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
opacity:
|
||||
pressed && email.trim().length > 3 ? 0.76 : undefined,
|
||||
})}
|
||||
>
|
||||
<Text className="text-[13px] font-black text-[#09090b]">
|
||||
继续
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View className="flex-row rounded-lg bg-white/[0.06] py-3.5">
|
||||
<Stat value="12" label="草稿" />
|
||||
<Stat value="4" label="已发布" />
|
||||
<Stat value={`${configuredCount}/4`} label="API" />
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between gap-4">
|
||||
<View>
|
||||
<Text className="text-[23px] font-black text-white">
|
||||
模型 API
|
||||
</Text>
|
||||
<Text className="mt-1 text-xs font-bold leading-[18px] text-white/60">
|
||||
对应 web 端文字、图片、视觉和 TTS 配置。
|
||||
</Text>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setShowKeys((value) => !value)}
|
||||
className="h-[42px] w-[42px] items-center justify-center rounded-full bg-white/10"
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.76 : undefined })}
|
||||
>
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: showKeys ? "eye.slash.fill" : "eye.fill",
|
||||
android: showKeys ? "visibility_off" : "visibility",
|
||||
web: showKeys ? "visibility_off" : "visibility",
|
||||
}}
|
||||
size={18}
|
||||
tintColor="#fff"
|
||||
/>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<View className="flex-row gap-2">
|
||||
{modelGroups.map((group) => {
|
||||
const selected = group.id === selectedRole;
|
||||
const configured = configs[group.id].apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
key={group.id}
|
||||
onPress={() => setSelectedRole(group.id)}
|
||||
className={`min-h-[42px] flex-1 flex-row items-center justify-center gap-1.5 rounded-lg border ${
|
||||
selected
|
||||
? "border-white/20 bg-white/[0.14]"
|
||||
: "border-white/10 bg-white/[0.07]"
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.76 : undefined,
|
||||
})}
|
||||
>
|
||||
<View
|
||||
className="h-[7px] w-[7px] rounded-full"
|
||||
style={{
|
||||
backgroundColor: configured ? group.accent : "#4a4a52",
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
className={`text-xs font-black ${selected ? "text-white" : "text-white/60"}`}
|
||||
>
|
||||
{roleLabels[group.id]}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
<View className="gap-3 rounded-lg border border-white/10 bg-white/[0.075] p-3.5">
|
||||
<View className="flex-row justify-between gap-3">
|
||||
<View>
|
||||
<Text className="text-[17px] font-black text-white">
|
||||
{selectedGroup.title}
|
||||
</Text>
|
||||
<Text className="mt-[3px] text-xs font-bold text-white/50">
|
||||
{selectedGroup.hint}
|
||||
</Text>
|
||||
</View>
|
||||
<View
|
||||
className={`h-[26px] justify-center rounded-full px-2.5 ${dirty ? "bg-[#ffd166]/15" : "bg-[#b8ff5d]/15"}`}
|
||||
>
|
||||
<Text
|
||||
className={`text-[11px] font-black ${dirty ? "text-[#ffd166]" : "text-[#b8ff5d]"}`}
|
||||
>
|
||||
{status === "loading"
|
||||
? "读取中"
|
||||
: dirty
|
||||
? "未保存"
|
||||
: "已保存"}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<InputLine
|
||||
label="Provider"
|
||||
value={selectedConfig.provider}
|
||||
onChangeText={(value) => updateConfig("provider", value)}
|
||||
placeholder="openai / claude / gemini / stepfun"
|
||||
/>
|
||||
<InputLine
|
||||
label="Base URL"
|
||||
value={selectedConfig.baseUrl}
|
||||
onChangeText={(value) => updateConfig("baseUrl", value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
<InputLine
|
||||
label="API Key"
|
||||
value={selectedConfig.apiKey}
|
||||
onChangeText={(value) => updateConfig("apiKey", value)}
|
||||
secureTextEntry={!showKeys}
|
||||
placeholder="sk-..."
|
||||
/>
|
||||
<InputLine
|
||||
label="Model"
|
||||
value={selectedConfig.model}
|
||||
onChangeText={(value) => updateConfig("model", value)}
|
||||
placeholder="model name"
|
||||
/>
|
||||
|
||||
<View className="mt-0.5 flex-row gap-2.5">
|
||||
<Pressable
|
||||
onPress={handleReset}
|
||||
className="h-11 justify-center rounded-full bg-white/10 px-[18px]"
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed ? 0.76 : undefined,
|
||||
})}
|
||||
>
|
||||
<Text className="text-sm font-black text-white">重置</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleSave}
|
||||
disabled={!dirty}
|
||||
className={`h-11 flex-1 flex-row items-center justify-center gap-[7px] rounded-full bg-white ${
|
||||
!dirty ? "opacity-40" : ""
|
||||
}`}
|
||||
style={({ pressed }) => ({
|
||||
opacity: pressed && dirty ? 0.76 : undefined,
|
||||
})}
|
||||
>
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: "checkmark.circle.fill",
|
||||
android: "check_circle",
|
||||
web: "check_circle",
|
||||
}}
|
||||
size={18}
|
||||
tintColor="#09090b"
|
||||
/>
|
||||
<Text className="text-sm font-black text-[#09090b]">
|
||||
保存配置
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex-row items-center justify-between gap-[18px] rounded-lg bg-white/[0.07] p-3.5">
|
||||
<View className="flex-1 gap-[3px]">
|
||||
<Text className="text-[15px] font-black text-white">
|
||||
云端同步
|
||||
</Text>
|
||||
<Text className="text-xs font-bold text-white/50">
|
||||
登录后自动同步我的剧情和配置。
|
||||
</Text>
|
||||
</View>
|
||||
<Switch
|
||||
value={syncEnabled}
|
||||
onValueChange={setSyncEnabled}
|
||||
trackColor={{ false: "#303036", true: "#ff4d6d" }}
|
||||
thumbColor="#fff"
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View className="overflow-hidden rounded-lg bg-white/[0.07]">
|
||||
<MenuItem
|
||||
name={{
|
||||
ios: "tray.and.arrow.down.fill",
|
||||
android: "download",
|
||||
web: "download",
|
||||
}}
|
||||
label="导入 .infiplot 剧情"
|
||||
/>
|
||||
<MenuItem
|
||||
name={{
|
||||
ios: "lock.shield.fill",
|
||||
android: "shield",
|
||||
web: "shield",
|
||||
}}
|
||||
label="隐私与本地密钥"
|
||||
/>
|
||||
<MenuItem
|
||||
name={{
|
||||
ios: "questionmark.circle.fill",
|
||||
android: "help",
|
||||
web: "help",
|
||||
}}
|
||||
label="帮助与反馈"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({ value, label }: { value: string; label: string }) {
|
||||
return (
|
||||
<View className="flex-1 items-center gap-1">
|
||||
<Text className="text-xl font-black text-white">{value}</Text>
|
||||
<Text className="text-xs font-extrabold text-white/50">{label}</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function InputLine({
|
||||
label,
|
||||
value,
|
||||
onChangeText,
|
||||
secureTextEntry,
|
||||
placeholder,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChangeText: (value: string) => void;
|
||||
secureTextEntry?: boolean;
|
||||
placeholder: string;
|
||||
}) {
|
||||
return (
|
||||
<View className="gap-1.5">
|
||||
<Text className="text-[11px] font-black text-white/50">{label}</Text>
|
||||
<TextInput
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
secureTextEntry={secureTextEntry}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
className="min-h-[42px] rounded-lg border border-white/10 bg-black/20 px-3 text-[13px] font-bold text-white"
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor="rgba(255,255,255,0.35)"
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuItem({ name, label }: { name: SymbolName; label: string }) {
|
||||
return (
|
||||
<Pressable
|
||||
className="min-h-14 flex-row items-center gap-3 border-b border-white/10 px-3.5"
|
||||
style={({ pressed }) => ({ opacity: pressed ? 0.76 : undefined })}
|
||||
>
|
||||
<View className="h-[34px] w-[34px] items-center justify-center rounded-full bg-white/10">
|
||||
<SymbolView name={name} size={18} tintColor="#fff" />
|
||||
</View>
|
||||
<Text className="flex-1 text-sm font-extrabold text-white">{label}</Text>
|
||||
<SymbolView
|
||||
name={{
|
||||
ios: "chevron.right",
|
||||
android: "chevron_right",
|
||||
web: "chevron_right",
|
||||
}}
|
||||
size={14}
|
||||
tintColor="rgba(255,255,255,0.4)"
|
||||
/>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
+22
-16
@@ -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 (
|
||||
<NativeTabs
|
||||
backgroundColor={colors.background}
|
||||
indicatorColor={colors.backgroundElement}
|
||||
labelStyle={{ selected: { color: colors.text } }}>
|
||||
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' },
|
||||
}}>
|
||||
<NativeTabs.Trigger name="index">
|
||||
<NativeTabs.Trigger.Label>Home</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Label>刷剧</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/home.png')}
|
||||
renderingMode="template"
|
||||
sf={{ default: 'play.rectangle', selected: 'play.rectangle.fill' }}
|
||||
md={{ default: 'slideshow', selected: 'slideshow' }}
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="explore">
|
||||
<NativeTabs.Trigger.Label>Explore</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger name="create">
|
||||
<NativeTabs.Trigger.Label>创作</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
src={require('@/assets/images/tabIcons/explore.png')}
|
||||
renderingMode="template"
|
||||
sf={{ default: 'wand.and.stars', selected: 'wand.and.stars.inverse' }}
|
||||
md={{ default: 'auto_fix_high', selected: 'auto_fix_high' }}
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
|
||||
<NativeTabs.Trigger name="profile">
|
||||
<NativeTabs.Trigger.Label>我的</NativeTabs.Trigger.Label>
|
||||
<NativeTabs.Trigger.Icon
|
||||
sf={{ default: 'person.crop.circle', selected: 'person.crop.circle.fill' }}
|
||||
md={{ default: 'person', selected: 'person' }}
|
||||
/>
|
||||
</NativeTabs.Trigger>
|
||||
</NativeTabs>
|
||||
|
||||
@@ -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<typeof SymbolView>['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 (
|
||||
<Tabs>
|
||||
<TabSlot style={{ height: '100%' }} />
|
||||
<TabSlot style={styles.slot} />
|
||||
<TabList asChild>
|
||||
<CustomTabList>
|
||||
<TabTrigger name="home" href="/" asChild>
|
||||
<TabButton>Home</TabButton>
|
||||
</TabTrigger>
|
||||
<TabTrigger name="explore" href="/explore" asChild>
|
||||
<TabButton>Explore</TabButton>
|
||||
</TabTrigger>
|
||||
</CustomTabList>
|
||||
<View style={styles.tabList}>
|
||||
{tabs.map((tab) => (
|
||||
<TabTrigger key={tab.name} name={tab.name} href={tab.href} asChild>
|
||||
<TabButton icon={tab.icon}>{tab.label}</TabButton>
|
||||
</TabTrigger>
|
||||
))}
|
||||
</View>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabButton({ children, isFocused, ...props }: TabTriggerSlotProps) {
|
||||
function TabButton({
|
||||
children,
|
||||
icon,
|
||||
isFocused,
|
||||
...props
|
||||
}: TabTriggerSlotProps & { icon: SymbolName }) {
|
||||
return (
|
||||
<Pressable {...props} style={({ pressed }) => pressed && styles.pressed}>
|
||||
<ThemedView
|
||||
type={isFocused ? 'backgroundSelected' : 'backgroundElement'}
|
||||
style={styles.tabButtonView}>
|
||||
<ThemedText type="small" themeColor={isFocused ? 'text' : 'textSecondary'}>
|
||||
{children}
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
<Pressable {...props} style={({ pressed }) => [styles.tabButton, isFocused && styles.activeTab, pressed && styles.pressed]}>
|
||||
<SymbolView name={icon} size={20} tintColor={isFocused ? '#fff' : 'rgba(255,255,255,0.5)'} />
|
||||
<Text style={[styles.tabText, isFocused && styles.activeText]}>{children}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export function CustomTabList(props: TabListProps) {
|
||||
const scheme = useColorScheme();
|
||||
const colors = Colors[scheme === 'unspecified' ? 'light' : scheme];
|
||||
|
||||
return (
|
||||
<View {...props} style={styles.tabListContainer}>
|
||||
<ThemedView type="backgroundElement" style={styles.innerContainer}>
|
||||
<ThemedText type="smallBold" style={styles.brandText}>
|
||||
Expo Starter
|
||||
</ThemedText>
|
||||
|
||||
{props.children}
|
||||
|
||||
<ExternalLink href="https://docs.expo.dev" asChild>
|
||||
<Pressable style={styles.externalPressable}>
|
||||
<ThemedText type="link">Docs</ThemedText>
|
||||
<SymbolView
|
||||
tintColor={colors.text}
|
||||
name={{ ios: 'arrow.up.right.square', web: 'link' }}
|
||||
size={12}
|
||||
/>
|
||||
</Pressable>
|
||||
</ExternalLink>
|
||||
</ThemedView>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -3,8 +3,6 @@
|
||||
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
|
||||
*/
|
||||
|
||||
import '@/global.css';
|
||||
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
export const Colors = {
|
||||
|
||||
+12
-5
@@ -1,9 +1,16 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--font-display:
|
||||
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji, Segoe UI Emoji,
|
||||
Segoe UI Symbol, Noto Color Emoji;
|
||||
Spline Sans, Inter, ui-sans-serif, system-ui, sans-serif, Apple Color Emoji,
|
||||
Segoe UI Emoji, Segoe UI Symbol, Noto Color Emoji;
|
||||
--font-mono:
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace;
|
||||
--font-rounded: 'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif;
|
||||
--font-serif: Georgia, 'Times New Roman', serif;
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono,
|
||||
Courier New, monospace;
|
||||
--font-rounded:
|
||||
"SF Pro Rounded", "Hiragino Maru Gothic ProN", Meiryo, "MS PGothic",
|
||||
sans-serif;
|
||||
--font-serif: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useColorScheme as useRNColorScheme } from 'react-native';
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { useColorScheme as useRNColorScheme } from "react-native";
|
||||
|
||||
/**
|
||||
* To support static rendering, this value needs to be re-calculated on the client side for web
|
||||
*/
|
||||
export function useColorScheme() {
|
||||
const [hasHydrated, setHasHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setHasHydrated(true);
|
||||
}, []);
|
||||
|
||||
const hasHydrated = useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
const colorScheme = useRNColorScheme();
|
||||
|
||||
if (hasHydrated) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
return 'light';
|
||||
return "light";
|
||||
}
|
||||
|
||||
@@ -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<ModelConfigMap> {
|
||||
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<ModelConfigMap> {
|
||||
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<ModelConfigMap> {
|
||||
try {
|
||||
const storage = getWebStorage();
|
||||
if (storage) storage.removeItem(STORAGE_KEY);
|
||||
} catch {
|
||||
// Storage can be unavailable in private sessions.
|
||||
}
|
||||
|
||||
memoryStore = null;
|
||||
return defaultModelConfig;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx,ts,tsx}", "./app/**/*.{js,jsx,ts,tsx}"],
|
||||
presets: [require("nativewind/preset")],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
+9
-1
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"jsxImportSource": "nativewind",
|
||||
"strict": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
@@ -8,6 +9,12 @@
|
||||
],
|
||||
"@/assets/*": [
|
||||
"./assets/*"
|
||||
],
|
||||
"@infiplot/core": [
|
||||
"../packages/core/src/index.ts"
|
||||
],
|
||||
"@infiplot/types": [
|
||||
"../packages/types/src/index.ts"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -15,6 +22,7 @@
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".expo/types/**/*.ts",
|
||||
"expo-env.d.ts"
|
||||
"expo-env.d.ts",
|
||||
"nativewind-env.d.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user