first commit :D

This commit is contained in:
Stef-00012 2025-01-21 13:55:00 +01:00
parent 40b7eabefe
commit ed5c91435d
No known key found for this signature in database
GPG key ID: 28BE9A9E4EF0E6BF
41 changed files with 724 additions and 907 deletions

2
.gitignore vendored
View file

@ -35,4 +35,4 @@ yarn-error.*
# typescript
*.tsbuildinfo
app-example
/android

View file

@ -1,41 +1,51 @@
{
"expo": {
"name": "test-share-target",
"slug": "test-share-target",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
]
],
"experiments": {
"typedRoutes": true
}
}
"expo": {
"name": "Zipline",
"slug": "zipline",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "zipline",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.stefdp.zipline"
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/images/adaptive-icon.png",
"backgroundColor": "#ffffff"
},
"package": "com.stefdp.zipline"
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff"
}
],
[
"expo-share-intent",
{
"androidIntentFilters": ["*/*"],
"androidMultiIntentFilters": ["*/*"],
"disableIOS": true
}
]
],
"experiments": {
"typedRoutes": true
}
}
}

View file

@ -1,45 +0,0 @@
import { Tabs } from 'expo-router';
import React from 'react';
import { Platform } from 'react-native';
import { HapticTab } from '@/components/HapticTab';
import { IconSymbol } from '@/components/ui/IconSymbol';
import TabBarBackground from '@/components/ui/TabBarBackground';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export default function TabLayout() {
const colorScheme = useColorScheme();
return (
<Tabs
screenOptions={{
tabBarActiveTintColor: Colors[colorScheme ?? 'light'].tint,
headerShown: false,
tabBarButton: HapticTab,
tabBarBackground: TabBarBackground,
tabBarStyle: Platform.select({
ios: {
// Use a transparent background on iOS to show the blur effect
position: 'absolute',
},
default: {},
}),
}}>
<Tabs.Screen
name="index"
options={{
title: 'Home',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="house.fill" color={color} />,
}}
/>
<Tabs.Screen
name="explore"
options={{
title: 'Explore',
tabBarIcon: ({ color }) => <IconSymbol size={28} name="paperplane.fill" color={color} />,
}}
/>
</Tabs>
);
}

View file

@ -1,109 +0,0 @@
import { StyleSheet, Image, Platform } from 'react-native';
import { Collapsible } from '@/components/Collapsible';
import { ExternalLink } from '@/components/ExternalLink';
import ParallaxScrollView from '@/components/ParallaxScrollView';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { IconSymbol } from '@/components/ui/IconSymbol';
export default function TabTwoScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#D0D0D0', dark: '#353636' }}
headerImage={
<IconSymbol
size={310}
color="#808080"
name="chevron.left.forwardslash.chevron.right"
style={styles.headerImage}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Explore</ThemedText>
</ThemedView>
<ThemedText>This app includes example code to help you get started.</ThemedText>
<Collapsible title="File-based routing">
<ThemedText>
This app has two screens:{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">app/(tabs)/explore.tsx</ThemedText>
</ThemedText>
<ThemedText>
The layout file in <ThemedText type="defaultSemiBold">app/(tabs)/_layout.tsx</ThemedText>{' '}
sets up the tab navigator.
</ThemedText>
<ExternalLink href="https://docs.expo.dev/router/introduction">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Android, iOS, and web support">
<ThemedText>
You can open this project on Android, iOS, and the web. To open the web version, press{' '}
<ThemedText type="defaultSemiBold">w</ThemedText> in the terminal running this project.
</ThemedText>
</Collapsible>
<Collapsible title="Images">
<ThemedText>
For static images, you can use the <ThemedText type="defaultSemiBold">@2x</ThemedText> and{' '}
<ThemedText type="defaultSemiBold">@3x</ThemedText> suffixes to provide files for
different screen densities
</ThemedText>
<Image source={require('@/assets/images/react-logo.png')} style={{ alignSelf: 'center' }} />
<ExternalLink href="https://reactnative.dev/docs/images">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Custom fonts">
<ThemedText>
Open <ThemedText type="defaultSemiBold">app/_layout.tsx</ThemedText> to see how to load{' '}
<ThemedText style={{ fontFamily: 'SpaceMono' }}>
custom fonts such as this one.
</ThemedText>
</ThemedText>
<ExternalLink href="https://docs.expo.dev/versions/latest/sdk/font">
<ThemedText type="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Light and dark mode components">
<ThemedText>
This template has light and dark mode support. The{' '}
<ThemedText type="defaultSemiBold">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="link">Learn more</ThemedText>
</ExternalLink>
</Collapsible>
<Collapsible title="Animations">
<ThemedText>
This template includes an example of an animated component. The{' '}
<ThemedText type="defaultSemiBold">components/HelloWave.tsx</ThemedText> component uses
the powerful <ThemedText type="defaultSemiBold">react-native-reanimated</ThemedText>{' '}
library to create a waving hand animation.
</ThemedText>
{Platform.select({
ios: (
<ThemedText>
The <ThemedText type="defaultSemiBold">components/ParallaxScrollView.tsx</ThemedText>{' '}
component provides a parallax effect for the header image.
</ThemedText>
),
})}
</Collapsible>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
headerImage: {
color: '#808080',
bottom: -90,
left: -35,
position: 'absolute',
},
titleContainer: {
flexDirection: 'row',
gap: 8,
},
});

View file

@ -1,74 +0,0 @@
import { Image, StyleSheet, Platform } from 'react-native';
import { HelloWave } from '@/components/HelloWave';
import ParallaxScrollView from '@/components/ParallaxScrollView';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
export default function HomeScreen() {
return (
<ParallaxScrollView
headerBackgroundColor={{ light: '#A1CEDC', dark: '#1D3D47' }}
headerImage={
<Image
source={require('@/assets/images/partial-react-logo.png')}
style={styles.reactLogo}
/>
}>
<ThemedView style={styles.titleContainer}>
<ThemedText type="title">Welcome!</ThemedText>
<HelloWave />
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 1: Try it</ThemedText>
<ThemedText>
Edit <ThemedText type="defaultSemiBold">app/(tabs)/index.tsx</ThemedText> to see changes.
Press{' '}
<ThemedText type="defaultSemiBold">
{Platform.select({
ios: 'cmd + d',
android: 'cmd + m',
web: 'F12'
})}
</ThemedText>{' '}
to open developer tools.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 2: Explore</ThemedText>
<ThemedText>
Tap the Explore tab to learn more about what's included in this starter app.
</ThemedText>
</ThemedView>
<ThemedView style={styles.stepContainer}>
<ThemedText type="subtitle">Step 3: Get a fresh start</ThemedText>
<ThemedText>
When you're ready, run{' '}
<ThemedText type="defaultSemiBold">npm run reset-project</ThemedText> to get a fresh{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> directory. This will move the current{' '}
<ThemedText type="defaultSemiBold">app</ThemedText> to{' '}
<ThemedText type="defaultSemiBold">app-example</ThemedText>.
</ThemedText>
</ThemedView>
</ParallaxScrollView>
);
}
const styles = StyleSheet.create({
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
stepContainer: {
gap: 8,
marginBottom: 8,
},
reactLogo: {
height: 178,
width: 290,
bottom: 0,
left: 0,
position: 'absolute',
},
});

View file

@ -1,32 +1,26 @@
import { Link, Stack } from 'expo-router';
import { StyleSheet } from 'react-native';
import { useRouter } from "expo-router";
import { Pressable, Text, View } from "react-native";
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { styles } from "@/styles/not-found";
export default function NotFoundScreen() {
return (
<>
<Stack.Screen options={{ title: 'Oops!' }} />
<ThemedView style={styles.container}>
<ThemedText type="title">This screen doesn't exist.</ThemedText>
<Link href="/" style={styles.link}>
<ThemedText type="link">Go to home screen!</ThemedText>
</Link>
</ThemedView>
</>
);
}
const router = useRouter();
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: 20,
},
link: {
marginTop: 15,
paddingVertical: 15,
},
});
return (
<View style={styles.container}>
<Text style={styles.code}>404</Text>
<Text style={styles.text}>This page doesn't exist</Text>
<Pressable
style={styles.button}
onPress={() => {
router.replace({
pathname: "/",
});
}}
>
Head to the Dashboard
</Pressable>
</View>
);
}

View file

@ -1,39 +1,26 @@
import { DarkTheme, DefaultTheme, ThemeProvider } from '@react-navigation/native';
import { useFonts } from 'expo-font';
import { Stack } from 'expo-router';
import * as SplashScreen from 'expo-splash-screen';
import { StatusBar } from 'expo-status-bar';
import { useEffect } from 'react';
import 'react-native-reanimated';
import Header from "@/components/Header";
import { Slot, useRouter } from "expo-router";
import { useColorScheme } from '@/hooks/useColorScheme';
import { ShareIntentProvider } from "expo-share-intent";
import { Text } from "react-native";
// Prevent the splash screen from auto-hiding before asset loading is complete.
SplashScreen.preventAutoHideAsync();
export default function Layout() {
const router = useRouter();
export default function RootLayout() {
const colorScheme = useColorScheme();
const [loaded] = useFonts({
SpaceMono: require('../assets/fonts/SpaceMono-Regular.ttf'),
});
useEffect(() => {
if (loaded) {
SplashScreen.hideAsync();
}
}, [loaded]);
if (!loaded) {
return null;
}
return (
<ThemeProvider value={colorScheme === 'dark' ? DarkTheme : DefaultTheme}>
<Stack>
<Stack.Screen name="(tabs)" options={{ headerShown: false }} />
<Stack.Screen name="+not-found" />
</Stack>
<StatusBar style="auto" />
</ThemeProvider>
);
return (
<ShareIntentProvider
options={{
scheme: "zipline",
debug: true,
resetOnBackground: true,
onResetShareIntent: () =>
router.replace({
pathname: "/",
}),
}}
>
<Header/>
<Slot />
</ShareIntentProvider>
);
}

115
app/index.tsx Normal file
View file

@ -0,0 +1,115 @@
import { useRouter } from "expo-router";
import { useShareIntentContext } from "expo-share-intent";
import { useEffect, useState } from "react";
import { Text, View, TextInput, Pressable } from "react-native";
import { styles } from "@/styles/home";
import { getRecentFiles, getStats, getUser } from "@/functions/zipline";
import type { APIRecentFiles, APIStats, APIUser } from "@/types/zipline";
import * as db from "@/functions/database";
export default function Home() {
const router = useRouter();
const { hasShareIntent } = useShareIntentContext();
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (hasShareIntent) {
router.replace({
pathname: "/shareintent",
});
}
}, [hasShareIntent]);
const [inputtedUrl, setInputtedUrl] = useState<string | null>(null);
const [inputtedToken, setInputtedToken] = useState<string | null>(null);
const [token, setToken] = useState<string | null>(db.get("token"));
const [url, setUrl] = useState<string | null>(db.get("url"));
const [user, setUser] = useState<APIUser | null>(null);
const [recentFiles, setRecentFiles] = useState<APIRecentFiles | null>();
const [stats, setStats] = useState<APIStats | null>();
const mainContainerStyles = user ? {
...styles.mainContainer
} : {
...styles.mainContainer,
marginTop: 0
};
useEffect(() => {
(async () => {
const user = await getUser();
const recentFiles = await getRecentFiles();
const stats = await getStats();
setUser(user);
setRecentFiles(recentFiles);
setStats(stats);
})();
});
async function handleLogin() {
const user = await getUser();
const recentFiles = await getRecentFiles();
const stats = await getStats();
setUser(user);
setRecentFiles(recentFiles);
setStats(stats);
}
return (
<View style={mainContainerStyles}>
{url && token ? (
<View style={{flex:1}}>
{user ? (
<View>
<Text style={{color: "#fff"}}>{user.username}</Text>
</View>
) : (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>Loading...</Text>
</View>
)}
</View>
) : (
<View style={styles.loginContainer}>
<View style={styles.loginBox}>
<TextInput
style={styles.textInput}
onChangeText={(content) => {
setInputtedUrl(content.length > 0 ? content : null);
}}
placeholder="Zipline URL"
placeholderTextColor="#222c47"
/>
<TextInput
style={styles.textInput}
secureTextEntry={true}
onChangeText={(content) => {
setInputtedToken(content.length > 0 ? content : null);
}}
placeholder="Zipline Token"
placeholderTextColor="#222c47"
/>
<Pressable
style={styles.button}
onPress={async (event) => {
setUrl(inputtedUrl);
db.set("url", inputtedUrl || "");
setToken(inputtedToken);
db.set("token", inputtedToken || "");
await handleLogin();
}}
>
<Text style={styles.buttonText}>Login</Text>
</Pressable>
</View>
</View>
)}
</View>
);
}

114
app/shareintent.tsx Normal file
View file

@ -0,0 +1,114 @@
import { Button, Image, StyleSheet, Text, View } from "react-native";
import { useRouter } from "expo-router";
import {
type ShareIntent as ShareIntentType,
useShareIntentContext,
} from "expo-share-intent";
const WebUrlComponent = ({ shareIntent }: { shareIntent: ShareIntentType }) => {
return (
<View
style={[
styles.gap,
styles.row,
{ borderWidth: 1, borderRadius: 5, height: 102 },
]}
>
<Image
source={
shareIntent.meta?.["og:image"]
? { uri: shareIntent.meta?.["og:image"] }
: undefined
}
style={[styles.icon, styles.gap, { borderRadius: 5 }]}
/>
<View style={{ flexShrink: 1, padding: 5 }}>
<Text style={[styles.gap]}>
{shareIntent.meta?.title || "<NO TITLE>"}
</Text>
<Text style={styles.gap}>{shareIntent.webUrl}</Text>
</View>
</View>
);
};
export default function ShareIntent() {
const router = useRouter();
const { hasShareIntent, shareIntent, error, resetShareIntent } =
useShareIntentContext();
console.log({
hasShareIntent,
shareIntent,
error,
resetShareIntent
})
return (
<View style={styles.container}>
{/* <Image
source={require("../assets/images/icon.png")}
style={[styles.logo, styles.gap]}
/> */}
{!hasShareIntent && <Text>No Share intent detected</Text>}
{hasShareIntent && (
<Text style={[styles.gap, { fontSize: 20 }]}>
Congratz, a share intent value is available
</Text>
)}
{!!shareIntent.text && <Text style={styles.gap}>{shareIntent.text}</Text>}
{shareIntent?.type === "weburl" && (
<WebUrlComponent shareIntent={shareIntent} />
)}
{shareIntent?.files?.map((file) => (
<Image
key={file.path}
source={{ uri: file.path }}
style={[styles.image, styles.gap]}
/>
))}
{hasShareIntent && (
<Button onPress={() => resetShareIntent()} title="Reset" />
)}
<Text style={[styles.error]}>{error}</Text>
<Button onPress={() => router.replace("/")} title="Go home" />
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 10,
},
logo: {
width: 75,
height: 75,
resizeMode: "contain",
},
image: {
width: 200,
height: 200,
resizeMode: "contain",
},
icon: {
width: 100,
height: 100,
resizeMode: "contain",
backgroundColor: "lightgray",
},
row: {
flexDirection: "row",
gap: 10,
},
gap: {
marginBottom: 20,
},
error: {
color: "red",
},
});

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

BIN
bun.lockb

Binary file not shown.

View file

@ -1,45 +0,0 @@
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { IconSymbol } from '@/components/ui/IconSymbol';
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={styles.heading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<IconSymbol
name="chevron.right"
size={18}
weight="medium"
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
style={{ transform: [{ rotate: isOpen ? '90deg' : '0deg' }] }}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={styles.content}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View file

@ -1,24 +0,0 @@
import { Link } from 'expo-router';
import { openBrowserAsync } from 'expo-web-browser';
import { type ComponentProps } from 'react';
import { Platform } from 'react-native';
type Props = Omit<ComponentProps<typeof Link>, 'href'> & { href: string };
export function ExternalLink({ href, ...rest }: Props) {
return (
<Link
target="_blank"
{...rest}
href={href}
onPress={async (event) => {
if (Platform.OS !== 'web') {
// Prevent the default behavior of linking to the default browser on native.
event.preventDefault();
// Open the link in an in-app browser.
await openBrowserAsync(href);
}
}}
/>
);
}

View file

@ -1,18 +0,0 @@
import { BottomTabBarButtonProps } from '@react-navigation/bottom-tabs';
import { PlatformPressable } from '@react-navigation/elements';
import * as Haptics from 'expo-haptics';
export function HapticTab(props: BottomTabBarButtonProps) {
return (
<PlatformPressable
{...props}
onPressIn={(ev) => {
if (process.env.EXPO_OS === 'ios') {
// Add a soft haptic feedback when pressing down on the tabs.
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
}
props.onPressIn?.(ev);
}}
/>
);
}

66
components/Header.tsx Normal file
View file

@ -0,0 +1,66 @@
import { Stack, IconButton } from "@react-native-material/core";
import MaterialIcons from "@expo/vector-icons/MaterialIcons";
import { View, Text, Pressable, Image } from "react-native";
import type React from "react";
import { styles } from "@/styles/header";
import { type PropsWithChildren, useEffect, useState } from "react";
import { getUser, getUserAvatar } from "@/functions/zipline";
import type { APIUser } from "@/types/zipline";
export default function Header() {
const [avatar, setAvatar] = useState<string | null>(null);
const [user, setUser] = useState<APIUser | null>(null);
useEffect(() => {
(async () => {
const avatar = await getUserAvatar();
const user = await getUser();
setAvatar(avatar);
setUser(user);
})();
}, []);
return (
<View>
{avatar && user ? (
<View>
<View style={styles.header}>
<View style={styles.headerLeft}>
<IconButton
icon={() => (
<MaterialIcons name="menu" color={"#fff"} size={40} />
)}
onPress={() => {
console.log("menu pressed");
}}
/>
</View>
<Stack>
<View>
<Pressable
onPress={() => {
console.log("user menu pressed");
}}
>
<View style={styles.userMenuContainer}>
<Image
source={{ uri: avatar }}
width={35}
height={35}
style={styles.userMenuAvatar}
/>
<Text style={styles.userMenuText}>{user.username}</Text>
</View>
</Pressable>
</View>
</Stack>
</View>
</View>
) : (
<View />
)}
</View>
);
}

View file

@ -1,40 +0,0 @@
import { useEffect } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
} from 'react-native-reanimated';
import { ThemedText } from '@/components/ThemedText';
export function HelloWave() {
const rotationAnimation = useSharedValue(0);
useEffect(() => {
rotationAnimation.value = withRepeat(
withSequence(withTiming(25, { duration: 150 }), withTiming(0, { duration: 150 })),
4 // Run the animation 4 times
);
}, []);
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotationAnimation.value}deg` }],
}));
return (
<Animated.View style={animatedStyle}>
<ThemedText style={styles.text}>👋</ThemedText>
</Animated.View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});

View file

@ -1,82 +0,0 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/ThemedView';
import { useBottomTabOverflow } from '@/components/ui/TabBarBackground';
import { useColorScheme } from '@/hooks/useColorScheme';
const HEADER_HEIGHT = 250;
type Props = PropsWithChildren<{
headerImage: ReactElement;
headerBackgroundColor: { dark: string; light: string };
}>;
export default function ParallaxScrollView({
children,
headerImage,
headerBackgroundColor,
}: Props) {
const colorScheme = useColorScheme() ?? 'light';
const scrollRef = useAnimatedRef<Animated.ScrollView>();
const scrollOffset = useScrollViewOffset(scrollRef);
const bottom = useBottomTabOverflow();
const headerAnimatedStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateY: interpolate(
scrollOffset.value,
[-HEADER_HEIGHT, 0, HEADER_HEIGHT],
[-HEADER_HEIGHT / 2, 0, HEADER_HEIGHT * 0.75]
),
},
{
scale: interpolate(scrollOffset.value, [-HEADER_HEIGHT, 0, HEADER_HEIGHT], [2, 1, 1]),
},
],
};
});
return (
<ThemedView style={styles.container}>
<Animated.ScrollView
ref={scrollRef}
scrollEventThrottle={16}
scrollIndicatorInsets={{ bottom }}
contentContainerStyle={{ paddingBottom: bottom }}>
<Animated.View
style={[
styles.header,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={styles.content}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: HEADER_HEIGHT,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

View file

@ -1,60 +0,0 @@
import { Text, type TextProps, StyleSheet } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedTextProps = TextProps & {
lightColor?: string;
darkColor?: string;
type?: 'default' | 'title' | 'defaultSemiBold' | 'subtitle' | 'link';
};
export function ThemedText({
style,
lightColor,
darkColor,
type = 'default',
...rest
}: ThemedTextProps) {
const color = useThemeColor({ light: lightColor, dark: darkColor }, 'text');
return (
<Text
style={[
{ color },
type === 'default' ? styles.default : undefined,
type === 'title' ? styles.title : undefined,
type === 'defaultSemiBold' ? styles.defaultSemiBold : undefined,
type === 'subtitle' ? styles.subtitle : undefined,
type === 'link' ? styles.link : undefined,
style,
]}
{...rest}
/>
);
}
const styles = StyleSheet.create({
default: {
fontSize: 16,
lineHeight: 24,
},
defaultSemiBold: {
fontSize: 16,
lineHeight: 24,
fontWeight: '600',
},
title: {
fontSize: 32,
fontWeight: 'bold',
lineHeight: 32,
},
subtitle: {
fontSize: 20,
fontWeight: 'bold',
},
link: {
lineHeight: 30,
fontSize: 16,
color: '#0a7ea4',
},
});

View file

@ -1,14 +0,0 @@
import { View, type ViewProps } from 'react-native';
import { useThemeColor } from '@/hooks/useThemeColor';
export type ThemedViewProps = ViewProps & {
lightColor?: string;
darkColor?: string;
};
export function ThemedView({ style, lightColor, darkColor, ...otherProps }: ThemedViewProps) {
const backgroundColor = useThemeColor({ light: lightColor, dark: darkColor }, 'background');
return <View style={[{ backgroundColor }, style]} {...otherProps} />;
}

View file

@ -1,10 +0,0 @@
import * as React from 'react';
import renderer from 'react-test-renderer';
import { ThemedText } from '../ThemedText';
it(`renders correctly`, () => {
const tree = renderer.create(<ThemedText>Snapshot test!</ThemedText>).toJSON();
expect(tree).toMatchSnapshot();
});

View file

@ -1,24 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders correctly 1`] = `
<Text
style={
[
{
"color": "#11181C",
},
{
"fontSize": 16,
"lineHeight": 24,
},
undefined,
undefined,
undefined,
undefined,
undefined,
]
}
>
Snapshot test!
</Text>
`;

View file

@ -1,32 +0,0 @@
import { SymbolView, SymbolViewProps, SymbolWeight } from 'expo-symbols';
import { StyleProp, ViewStyle } from 'react-native';
export function IconSymbol({
name,
size = 24,
color,
style,
weight = 'regular',
}: {
name: SymbolViewProps['name'];
size?: number;
color: string;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return (
<SymbolView
weight={weight}
tintColor={color}
resizeMode="scaleAspectFit"
name={name}
style={[
{
width: size,
height: size,
},
style,
]}
/>
);
}

View file

@ -1,43 +0,0 @@
// This file is a fallback for using MaterialIcons on Android and web.
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { SymbolWeight } from 'expo-symbols';
import React from 'react';
import { OpaqueColorValue, StyleProp, ViewStyle } from 'react-native';
// Add your SFSymbol to MaterialIcons mappings here.
const MAPPING = {
// See MaterialIcons here: https://icons.expo.fyi
// See SF Symbols in the SF Symbols app on Mac.
'house.fill': 'home',
'paperplane.fill': 'send',
'chevron.left.forwardslash.chevron.right': 'code',
'chevron.right': 'chevron-right',
} as Partial<
Record<
import('expo-symbols').SymbolViewProps['name'],
React.ComponentProps<typeof MaterialIcons>['name']
>
>;
export type IconSymbolName = keyof typeof MAPPING;
/**
* An icon component that uses native SFSymbols on iOS, and MaterialIcons on Android and web. This ensures a consistent look across platforms, and optimal resource usage.
*
* Icon `name`s are based on SFSymbols and require manual mapping to MaterialIcons.
*/
export function IconSymbol({
name,
size = 24,
color,
style,
}: {
name: IconSymbolName;
size?: number;
color: string | OpaqueColorValue;
style?: StyleProp<ViewStyle>;
weight?: SymbolWeight;
}) {
return <MaterialIcons color={color} size={size} name={MAPPING[name]} style={style} />;
}

View file

@ -1,22 +0,0 @@
import { useBottomTabBarHeight } from '@react-navigation/bottom-tabs';
import { BlurView } from 'expo-blur';
import { StyleSheet } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
export default function BlurTabBarBackground() {
return (
<BlurView
// System chrome material automatically adapts to the system's theme
// and matches the native tab bar appearance on iOS.
tint="systemChromeMaterial"
intensity={100}
style={StyleSheet.absoluteFill}
/>
);
}
export function useBottomTabOverflow() {
const tabHeight = useBottomTabBarHeight();
const { bottom } = useSafeAreaInsets();
return tabHeight - bottom;
}

View file

@ -1,6 +0,0 @@
// This is a shim for web and Android where the tab bar is generally opaque.
export default undefined;
export function useBottomTabOverflow() {
return 0;
}

View file

@ -1,26 +0,0 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* 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.
*/
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};

15
functions/database.ts Normal file
View file

@ -0,0 +1,15 @@
import * as SecureStore from "expo-secure-store";
export function set(key: string, value: string) {
SecureStore.setItem(key, value);
}
export function get(key: string) {
const result = SecureStore.getItem(key);
if (result) {
return result;
}
return null;
}

79
functions/zipline.ts Normal file
View file

@ -0,0 +1,79 @@
import * as db from "@/functions/database";
import type { APIRecentFiles, APIStats, APIUser } from "@/types/zipline";
import axios from "axios";
export async function getUser() {
const token = db.get("token")
const url = db.get("url")
if (!url || !token) return null;
try {
const res = await axios.get(`${url}/api/user`, {
headers: {
Authorization: token
}
})
return res.data.user as APIUser;
} catch(e) {
return null;
}
}
export async function getRecentFiles() {
const token = db.get("token")
const url = db.get("url")
if (!url || !token) return null;
try {
const res = await axios.get(`${url}/api/user/recent`, {
headers: {
Authorization: token
}
})
return res.data as APIRecentFiles;
} catch(e) {
return null;
}
}
export async function getStats() {
const token = db.get("token")
const url = db.get("url")
if (!url || !token) return null;
try {
const res = await axios.get(`${url}/api/user/stats`, {
headers: {
Authorization: token
}
})
return res.data as APIStats;
} catch(e) {
return null;
}
}
export async function getUserAvatar() {
const token = db.get("token")
const url = db.get("url")
if (!url || !token) return null;
try {
const res = await axios.get(`${url}/api/user/avatar`, {
headers: {
Authorization: token
}
})
return res.data as string;
} catch(e) {
return null;
}
}

View file

@ -1 +0,0 @@
export { useColorScheme } from 'react-native';

View file

@ -1,21 +0,0 @@
import { useEffect, useState } 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 colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}

View file

@ -1,21 +0,0 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/Colors';
import { useColorScheme } from '@/hooks/useColorScheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}

View file

@ -1,12 +1,12 @@
{
"name": "test-share-target",
"name": "zipline",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo start --android",
"ios": "expo start --ios",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"test": "jest --watchAll",
"lint": "expo lint"
@ -16,8 +16,10 @@
},
"dependencies": {
"@expo/vector-icons": "^14.0.2",
"@react-native-material/core": "^1.3.7",
"@react-navigation/bottom-tabs": "^7.2.0",
"@react-navigation/native": "^7.0.14",
"axios": "^1.7.9",
"expo": "~52.0.26",
"expo-blur": "~14.0.2",
"expo-constants": "~17.0.4",
@ -25,6 +27,8 @@
"expo-haptics": "~14.0.1",
"expo-linking": "~7.0.4",
"expo-router": "~4.0.17",
"expo-secure-store": "^14.0.1",
"expo-share-intent": "^3.2.0",
"expo-splash-screen": "~0.29.21",
"expo-status-bar": "~2.0.1",
"expo-symbols": "~0.2.1",

View file

@ -1,84 +0,0 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const newDir = "app-example";
const newAppDir = "app";
const newDirPath = path.join(root, newDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const moveDirectories = async () => {
try {
// Create the app-example directory
await fs.promises.mkdir(newDirPath, { recursive: true });
console.log(`📁 /${newDir} directory created.`);
// Move old directories to new app-example directory
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
const newDirPath = path.join(root, newDir, dir);
if (fs.existsSync(oldDirPath)) {
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${newDir}/${dir}.`);
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
"1. Run `npx expo start` to start a development server.\n2. Edit app/index.tsx to edit the main screen.\n3. Delete the /app-example directory when you're done referencing it."
);
} catch (error) {
console.error(`Error during script execution: ${error}`);
}
};
moveDirectories();

53
styles/header.ts Normal file
View file

@ -0,0 +1,53 @@
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
header: {
position: "absolute",
top: 0,
left: 0,
right: 0,
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 10,
backgroundColor: "#0c101c",
zIndex: 99999,
borderBottomWidth: 2,
borderBottomColor: "#222c47",
height: 70
},
headerLeft: {
flexDirection: "column",
},
text: {
fontFamily: "Arimo-Nerd-Font",
color: "#fff",
},
settings: {
width: 30,
height: 30,
marginRight: 10,
},
settingsIcon: {
width: "100%",
height: "100%",
},
userMenuContainer: {
flexWrap: "wrap",
flexDirection: "row",
},
userMenuAvatar: {
borderRadius: 8
},
userMenuText: {
color: "white",
fontWeight: "bold",
marginLeft: 10,
marginTop: 5,
fontSize: 18
}
})

59
styles/home.ts Normal file
View file

@ -0,0 +1,59 @@
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
mainContainer: {
backgroundColor: "#0c101c",
flex: 1,
marginTop: 50
},
loadingContainer: {
flex: 1,
display: "flex"
},
loadingText: {
fontSize: 40,
fontWeight: "bold",
margin: "auto",
justifyContent: "center",
alignItems: "center",
color: "#304270",
},
loginContainer: {
display: "flex",
flex: 1,
backgroundColor: "#181c28",
justifyContent: "center",
alignContent: "center",
},
loginBox: {
width: "80%",
borderWidth: 4,
borderColor: "#222c47",
borderStyle: "solid",
padding: 10,
borderRadius: 10,
margin: "auto"
},
textInput: {
width: "100%",
borderWidth: 2,
borderColor: "#222c47",
marginBottom: 5,
marginTop: 5,
color: "white",
padding: 3,
borderRadius: 6
},
button: {
width: "100%",
backgroundColor: "#323ea8",
padding: 10,
marginTop: 5,
borderRadius: 6
},
buttonText: {
textAlign: "center",
fontWeight: "bold",
color: "white"
}
});

21
styles/not-found.ts Normal file
View file

@ -0,0 +1,21 @@
import { StyleSheet } from "react-native";
export const styles = StyleSheet.create({
container: {
display: "flex",
flex: 1,
justifyContent: "center",
},
code: {
color: "white",
fontSize: 40
},
text: {
color: "#6c7a8d",
fontSize: 20,
},
button: {
color: "white",
backgroundColor: "#323ea8"
}
});

101
types/zipline.ts Normal file
View file

@ -0,0 +1,101 @@
// /api/user
export interface APIUser {
id: string;
username: string;
createdAt: string;
updatedAt: string;
role: "SUPERADMIN" | "ADMIN" | "USER";
view: APIUserView;
oauthProviders: Array<OAuthProvider>;
totpSecret: string;
passkeys: Array<Passkey>;
quota: null;
sessions: Array<string>;
}
export interface APIUserView {
enabled: boolean;
align: "left" | "center" | "right";
showMimetype: boolean;
content: string;
embed: boolean;
embedTitle: string;
embedDescription: string;
embedColor: string;
embedSiteName: string;
}
export interface OAuthProvider {
id: string;
createdAt: string;
updatedAt: string;
userId: string;
provider: string;
username: string;
accessToken: string;
refreshToken: string;
oauthId: string;
}
export interface Passkey {
id: string;
createdAt: string;
updatedAt: string;
lastUsed: string;
name: string;
reg: PasskeyReg;
userId: string;
}
export interface PasskeyReg {
id: string;
type: string;
rawId: string;
response: PasskeyRegResponse;
clientExtensionResults: PasskeyRegClientExtensionResults;
authenticatorAttachment: string;
}
export interface PasskeyRegResponse {
transports: Array<string>;
clientDataJSON: string;
attestationObject: string;
}
export interface PasskeyRegClientExtensionResults {
credProps: null;
}
export type APIRecentFiles = Array<APIFile>;
export interface APIFile {
createdAt: string;
updatedAt: string;
deletesAt: string | null;
favorite: boolean;
id: string;
originalName: string | null;
size: number;
type: string;
views: number;
maxViews: number | null;
thumbnail: string | null;
tags: Array<string>;
password: string | null;
url: string;
}
export interface APIStats {
filesUploaded: number;
favoriteFiles: number;
views: number;
avgViews: number;
storageUsed: number;
avgStorageUsed: number;
urlsCreated: number;
urlViews: number;
sortTypeCount: {
[key: string]: number;
};
}