add initial configuration files and implement color scheme hooks

This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2025-03-30 16:42:51 -03:00
parent bb3cd0120c
commit 85a5e1d0de
140 changed files with 18020 additions and 164 deletions

37
app.json Normal file
View file

@ -0,0 +1,37 @@
{
"expo": {
"name": "online",
"slug": "online",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./images/icon.png",
"scheme": "myapp",
"userInterfaceStyle": "automatic",
"splash": {
"image": "./images/splash.png",
"resizeMode": "contain",
"backgroundColor": "#ffffff"
},
"ios": {
"supportsTablet": true
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./images/adaptive-icon.png",
"backgroundColor": "#ffffff"
}
},
"web": {
"bundler": "metro",
"output": "static",
"favicon": "./public/images/favicon.png"
},
"plugins": [
"expo-router"
],
"experiments": {
"tsconfigPaths": true,
"typedRoutes": true
}
}
}

7
babel.config.js Normal file
View file

@ -0,0 +1,7 @@
module.exports = function (api) {
api.cache(true);
return {
presets: ['babel-preset-expo'],
plugins: ['nativewind/babel']
};
};

View file

@ -0,0 +1,41 @@
import Ionicons from '@expo/vector-icons/Ionicons';
import { PropsWithChildren, useState } from 'react';
import { StyleSheet, TouchableOpacity, useColorScheme } from 'react-native';
import { ThemedText } from '@/components/ThemedText';
import { ThemedView } from '@/components/ThemedView';
import { Colors } from '@/constants/Colors';
export function Collapsible({ children, title }: PropsWithChildren & { title: string }) {
const [isOpen, setIsOpen] = useState(false);
const theme = useColorScheme() ?? 'light';
return (
<ThemedView>
<TouchableOpacity
style={globalStylesheading}
onPress={() => setIsOpen((value) => !value)}
activeOpacity={0.8}>
<Ionicons
name={isOpen ? 'chevron-down' : 'chevron-forward-outline'}
size={18}
color={theme === 'light' ? Colors.light.icon : Colors.dark.icon}
/>
<ThemedText type="defaultSemiBold">{title}</ThemedText>
</TouchableOpacity>
{isOpen && <ThemedView style={globalStylescontent}>{children}</ThemedView>}
</ThemedView>
);
}
const styles = StyleSheet.create({
heading: {
flexDirection: 'row',
alignItems: 'center',
gap: 6,
},
content: {
marginTop: 6,
marginLeft: 24,
},
});

View file

@ -0,0 +1,24 @@
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);
}
}}
/>
);
}

37
components/HelloWave.tsx Normal file
View file

@ -0,0 +1,37 @@
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);
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={globalStylestext}>👋</ThemedText>
</Animated.View>
);
}
const styles = StyleSheet.create({
text: {
fontSize: 28,
lineHeight: 32,
marginTop: -6,
},
});

View file

@ -0,0 +1,76 @@
import type { PropsWithChildren, ReactElement } from 'react';
import { StyleSheet, useColorScheme } from 'react-native';
import Animated, {
interpolate,
useAnimatedRef,
useAnimatedStyle,
useScrollViewOffset,
} from 'react-native-reanimated';
import { ThemedView } from '@/components/ThemedView';
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 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={globalStylescontainer}>
<Animated.ScrollView ref={scrollRef} scrollEventThrottle={16}>
<Animated.View
style={[
globalStylesheader,
{ backgroundColor: headerBackgroundColor[colorScheme] },
headerAnimatedStyle,
]}>
{headerImage}
</Animated.View>
<ThemedView style={globalStylescontent}>{children}</ThemedView>
</Animated.ScrollView>
</ThemedView>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
},
header: {
height: 250,
overflow: 'hidden',
},
content: {
flex: 1,
padding: 32,
gap: 16,
overflow: 'hidden',
},
});

60
components/ThemedText.tsx Normal file
View file

@ -0,0 +1,60 @@
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' ? globalStylesdefault : undefined,
type === 'title' ? globalStylestitle : undefined,
type === 'defaultSemiBold' ? globalStylesdefaultSemiBold : undefined,
type === 'subtitle' ? globalStylessubtitle : undefined,
type === 'link' ? globalStyleslink : 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',
},
});

14
components/ThemedView.tsx Normal file
View file

@ -0,0 +1,14 @@
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

@ -0,0 +1,10 @@
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

@ -0,0 +1,24 @@
// 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

@ -0,0 +1,9 @@
// You can explore the built-in icon families and icons on the web at https://icons.expo.fyi/
import Ionicons from '@expo/vector-icons/Ionicons';
import { type IconProps } from '@expo/vector-icons/build/createIconSet';
import { type ComponentProps } from 'react';
export function TabBarIcon({ style, ...rest }: IconProps<ComponentProps<typeof Ionicons>['name']>) {
return <Ionicons size={28} style={[{ marginBottom: -3 }, style]} {...rest} />;
}

26
constants/Colors.ts Normal file
View file

@ -0,0 +1,26 @@
/**
* 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,
},
};

1
hooks/useColorScheme.ts Normal file
View file

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

View file

@ -0,0 +1,8 @@
// NOTE: The default React Native styling doesn't support server rendering.
// Server rendered styles should not change between the first render of the HTML
// and the first render on the client. Typically, web developers will use CSS media queries
// to render different styles on the client and server, these aren't directly supported in React Native
// but can be achieved using a styling library like Nativewind.
export function useColorScheme() {
return 'light';
}

22
hooks/useThemeColor.ts Normal file
View file

@ -0,0 +1,22 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { useColorScheme } from 'react-native';
import { Colors } from '@/constants/Colors';
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

@ -10,20 +10,57 @@
"tauri": "tauri"
},
"dependencies": {
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-opener": "^2"
"@babel/runtime": "7.26.0",
"@hookform/resolvers": "3.9.1",
"@react-native-async-storage/async-storage": "2.1.0",
"@react-native-community/datetimepicker": "8.2.0",
"@react-native-community/slider": "4.5.5",
"@react-native-picker/picker": "2.9.0",
"@react-navigation/native": "6.x",
"@react-navigation/stack": "6.x",
"@tauri-apps/api": "2",
"@tauri-apps/plugin-opener": "2",
"@zitadel/react": "1.0.5",
"autoprefixer": "10.4.17",
"botframework-directlinejs": "0.15.1",
"botframework-webchat": "4.15.7",
"date-fns": "2.30.0",
"lucide-react-native": "0.469.0",
"lucide-react": "0.454.0",
"nativewind": "2.0.10",
"postcss": "8.4.35",
"react-dom": "18.3.1",
"react-hook-form": "7.53.2",
"react-native-chart-kit": "6.12.0",
"react-native-elements": "3.4.3",
"react-native-gesture-handler": "2.16.1",
"react-native-linear-gradient": "2.8.3",
"react-native-markdown-display": "7.0.0-alpha.2",
"react-native-reanimated": "3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-web": "0.19.6",
"react-native": "0.74.5",
"react": "18.3.1",
"tailwindcss": "3.4.1",
"uuid": "11.0.3",
"zod": "3.21.4"
},
"devDependencies": {
"@types/react": "^18.3.1",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "~5.6.2",
"vite": "^6.0.3",
"@tauri-apps/cli": "^2"
"@babel/core": "7.18.6",
"@types/jest": "29.5.12",
"@types/react-test-renderer": "18.0.7",
"copy-webpack-plugin": "12.0.2",
"jest": "29.2.1",
"jest-expo": "51.0.3",
"postcss": "8.4.23",
"react-test-renderer": "18.2.0",
"tailwindcss": "3.1.8",
"@types/react": "18.3.1",
"@types/react-dom": "18.3.1",
"@vitejs/plugin-react": "4.3.4",
"typescript": "5.6.2",
"vite": "6.0.3",
"@tauri-apps/cli": "2"
}
}

11557
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

6
postcss.config.js Normal file
View file

@ -0,0 +1,6 @@
module.exports = {
plugins: [
require('tailwindcss'),
require('autoprefixer'),
],
};

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

BIN
public/images/badge.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
public/images/bg.jpeg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

BIN
public/images/emoji1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

BIN
public/images/emoji2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
public/images/emoji3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

BIN
public/images/emoji4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

BIN
public/images/emoji5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

BIN
public/images/emoji6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

BIN
public/images/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
public/images/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,5 @@
<svg width="21" height="22" viewBox="0 0 21 22" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15.3795 1.30176H5.6812C3.00308 1.30176 0.832031 3.47281 0.832031 6.15093V15.8493C0.832031 18.5274 3.00308 20.6984 5.6812 20.6984H15.3795C18.0577 20.6984 20.2287 18.5274 20.2287 15.8493V6.15093C20.2287 3.47281 18.0577 1.30176 15.3795 1.30176Z" stroke="#7657AA" stroke-width="1.49205" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M14.4101 10.3892C14.5298 11.1963 14.3919 12.0206 14.0161 12.7449C13.6403 13.4692 13.0457 14.0565 12.3168 14.4234C11.588 14.7902 10.762 14.9179 9.95639 14.7883C9.15079 14.6586 8.40657 14.2783 7.8296 13.7013C7.25262 13.1243 6.87227 12.3801 6.74263 11.5745C6.613 10.7689 6.74069 9.94294 7.10754 9.21409C7.47439 8.48524 8.06172 7.89062 8.78599 7.51481C9.51027 7.139 10.3346 7.00113 11.1417 7.12082C11.9651 7.24291 12.7273 7.62656 13.3158 8.21509C13.9043 8.80363 14.288 9.56585 14.4101 10.3892Z" stroke="#7657AA" stroke-width="1.49205" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M15.8643 5.66602H15.8747" stroke="#7657AA" stroke-width="2.23808" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

BIN
public/images/logo-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

BIN
public/images/mercury.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/images/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

46
public/sounds/click.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/error.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/hover.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

13
public/sounds/manifest.ts Normal file
View file

@ -0,0 +1,13 @@
export const soundAssets = {
send: '/assets/sounds/send.mp3',
receive: '/assets/sounds/receive.mp3',
typing: '/assets/sounds/typing.mp3',
notification: '/assets/sounds/notification.mp3',
click: '/assets/sounds/click.mp3',
hover: '/assets/sounds/hover.mp3',
success: '/assets/sounds/success.mp3',
error: '/assets/sounds/error.mp3'
} as const;
// Type for sound names
export type SoundName = keyof typeof soundAssets;

View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/receive.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/send.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/success.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

46
public/sounds/typing.mp3 Normal file
View file

@ -0,0 +1,46 @@
<!doctype html>
<html>
<head>
<title>Example Domain</title>
<meta charset="utf-8" />
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type="text/css">
body {
background-color: #f0f0f2;
margin: 0;
padding: 0;
font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif;
}
div {
width: 600px;
margin: 5em auto;
padding: 2em;
background-color: #fdfdff;
border-radius: 0.5em;
box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02);
}
a:link, a:visited {
color: #38488f;
text-decoration: none;
}
@media (max-width: 700px) {
div {
margin: 0 auto;
width: auto;
}
}
</style>
</head>
<body>
<div>
<h1>Example Domain</h1>
<p>This domain is for use in illustrative examples in documents. You may use this
domain in literature without prior coordination or asking for permission.</p>
<p><a href="https://www.iana.org/domains/example">More information...</a></p>
</div>
</body>
</html>

119
src-tauri/src/drive.rs Normal file
View file

@ -0,0 +1,119 @@
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{Manager, Window};
#[derive(Debug, Serialize, Deserialize)]
struct FileItem {
name: String,
path: String,
is_dir: bool,
}
impl Drive {
#[tauri::command]
fn list_files(path: &str) -> Result<Vec<FileItem>, String> {
let base_path = Path::new(path);
let mut files = Vec::new();
if !base_path.exists() {
return Err("Path does not exist".into());
}
for entry in fs::read_dir(base_path).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
files.push(FileItem {
name,
path: path.to_str().unwrap_or("").to_string(),
is_dir: path.is_dir(),
});
}
// Sort directories first, then files
files.sort_by(|a, b| {
if a.is_dir && !b.is_dir {
std::cmp::Ordering::Less
} else if !a.is_dir && b.is_dir {
std::cmp::Ordering::Greater
} else {
a.name.cmp(&b.name)
}
});
Ok(files)
}
#[tauri::command]
async fn upload_file(
window: Window,
src_path: String,
dest_path: String,
) -> Result<(), String> {
use std::fs::File;
use std::io::{Read, Write};
use tauri::api::path::home_dir;
let src = PathBuf::from(&src_path);
let dest_dir = PathBuf::from(&dest_path);
let dest = dest_dir.join(src.file_name().ok_or("Invalid source file")?);
// Create destination directory if it doesn't exist
if !dest_dir.exists() {
fs::create_dir_all(&dest_dir).map_err(|e| e.to_string())?;
}
let mut source_file = File::open(&src).map_err(|e| e.to_string())?;
let mut dest_file = File::create(&dest).map_err(|e| e.to_string())?;
let file_size = source_file.metadata().map_err(|e| e.to_string())?.len();
let mut buffer = [0; 8192];
let mut total_read = 0;
loop {
let bytes_read = source_file.read(&mut buffer).map_err(|e| e.to_string())?;
if bytes_read == 0 {
break;
}
dest_file
.write_all(&buffer[..bytes_read])
.map_err(|e| e.to_string())?;
total_read += bytes_read as u64;
let progress = (total_read as f64 / file_size as f64) * 100.0;
window
.emit("upload_progress", progress)
.map_err(|e| e.to_string())?;
}
Ok(())
}
#[tauri::command]
fn create_folder(path: String, name: String) -> Result<(), String> {
let full_path = Path::new(&path).join(&name);
if full_path.exists() {
return Err("Folder already exists".into());
}
fs::create_dir(full_path).map_err(|e| e.to_string())?;
Ok(())
}
fn main() {
tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
list_files,
upload_file,
create_folder
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
}

View file

@ -1,152 +1,17 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
use serde::{Deserialize, Serialize};
use tauri::{Manager, Window};
use std::sync::Mutex;
use std::process::{Command, Stdio};
use std::path::Path;
use std::fs::{File, OpenOptions, create_dir_all};
use std::io::Write;
use std::env;
use std::fs::{create_dir_all, File, OpenOptions};
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};
use std::sync::Mutex;
use tauri::{Manager, Window};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RcloneConfig {
name: String,
remote_path: String,
local_path: String,
access_key: String,
secret_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SyncStatus {
name: String,
status: String,
transferred: String,
bytes: String,
errors: usize,
last_updated: String,
}
struct AppState {
sync_processes: Mutex<Vec<std::process::Child>>,
sync_active: Mutex<bool>,
}
#[tauri::command]
fn save_config(config: RcloneConfig) -> Result<(), String> {
let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?;
let config_path = Path::new(&home_dir).join(".config/rclone/rclone.conf");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&config_path)
.map_err(|e| format!("Failed to open config file: {}", e))?;
writeln!(file, "[{}]", config.name)
.and_then(|_| writeln!(file, "type = s3"))
.and_then(|_| writeln!(file, "provider = Other"))
.and_then(|_| writeln!(file, "access_key_id = {}", config.access_key))
.and_then(|_| writeln!(file, "secret_access_key = {}", config.secret_key))
.and_then(|_| writeln!(file, "endpoint = https://drive-api.pragmatismo.com.br"))
.and_then(|_| writeln!(file, "acl = private"))
.map_err(|e| format!("Failed to write config: {}", e))
}
#[tauri::command]
fn start_sync(config: RcloneConfig, state: tauri::State<AppState>) -> Result<(), String> {
let local_path = Path::new(&config.local_path);
if !local_path.exists() {
create_dir_all(local_path).map_err(|e| format!("Failed to create local path: {}", e))?;
}
let child = Command::new("rclone")
.arg("sync")
.arg(&config.remote_path)
.arg(&config.local_path)
.arg("--no-check-certificate")
.arg("--verbose")
.arg("--rc")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start rclone: {}", e))?;
state.sync_processes.lock().unwrap().push(child);
*state.sync_active.lock().unwrap() = true;
Ok(())
}
#[tauri::command]
fn stop_sync(state: tauri::State<AppState>) -> Result<(), String> {
let mut processes = state.sync_processes.lock().unwrap();
for child in processes.iter_mut() {
child.kill().map_err(|e| format!("Failed to kill process: {}", e))?;
}
processes.clear();
*state.sync_active.lock().unwrap() = false;
Ok(())
}
#[tauri::command]
fn get_status(remote_name: String) -> Result<SyncStatus, String> {
let output = Command::new("rclone")
.arg("rc")
.arg("core/stats")
.arg("--json")
.output()
.map_err(|e| format!("Failed to execute rclone rc: {}", e))?;
if !output.status.success() {
return Err(format!("rclone rc failed: {}", String::from_utf8_lossy(&output.stderr)));
}
let json = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse rclone status: {}", e))?;
let transferred = value.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0);
let errors = value.get("errors").and_then(|v| v.as_u64()).unwrap_or(0);
let speed = value.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0);
let status = if errors > 0 {
"Error occurred".to_string()
} else if speed > 0.0 {
"Transferring".to_string()
} else if transferred > 0 {
"Completed".to_string()
} else {
"Initializing".to_string()
};
Ok(SyncStatus {
name: remote_name,
status,
transferred: format_bytes(transferred),
bytes: format!("{}/s", format_bytes(speed as u64)),
errors: errors as usize,
last_updated: chrono::Local::now().format("%H:%M:%S").to_string(),
})
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
pub mod drive;
use drive::Drive::;
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
@ -157,16 +22,18 @@ fn greet(name: &str) -> String {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(AppState {
sync_processes: Mutex::new(Vec::new()),
sync_active: Mutex::new(false),
})
.plugin(tauri_plugin_opener::init())
.manage(AppState {
sync_processes: Mutex::new(Vec::new()),
sync_active: Mutex::new(false),
})
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
save_config,
list_files,
start_sync,
stop_sync,
get_status])
get_status
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

View file

@ -1,6 +1,8 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
pub mod drive;
fn main() {
my_tauri_app_lib::run()
}

146
src-tauri/src/sync.rs Normal file
View file

@ -0,0 +1,146 @@
use serde::{Deserialize, Serialize};
use tauri::{Manager, Window};
use std::sync::Mutex;
use std::process::{Command, Stdio};
use std::path::Path;
use std::fs::{File, OpenOptions, create_dir_all};
use std::io::Write;
use std::env;
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RcloneConfig {
name: String,
remote_path: String,
local_path: String,
access_key: String,
secret_key: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct SyncStatus {
name: String,
status: String,
transferred: String,
bytes: String,
errors: usize,
last_updated: String,
}
struct AppState {
sync_processes: Mutex<Vec<std::process::Child>>,
sync_active: Mutex<bool>,
}
#[tauri::command]
fn save_config(config: RcloneConfig) -> Result<(), String> {
let home_dir = env::var("HOME").map_err(|_| "HOME environment variable not set".to_string())?;
let config_path = Path::new(&home_dir).join(".config/rclone/rclone.conf");
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&config_path)
.map_err(|e| format!("Failed to open config file: {}", e))?;
writeln!(file, "[{}]", config.name)
.and_then(|_| writeln!(file, "type = s3"))
.and_then(|_| writeln!(file, "provider = Other"))
.and_then(|_| writeln!(file, "access_key_id = {}", config.access_key))
.and_then(|_| writeln!(file, "secret_access_key = {}", config.secret_key))
.and_then(|_| writeln!(file, "endpoint = https://drive-api.pragmatismo.com.br"))
.and_then(|_| writeln!(file, "acl = private"))
.map_err(|e| format!("Failed to write config: {}", e))
}
#[tauri::command]
fn start_sync(config: RcloneConfig, state: tauri::State<AppState>) -> Result<(), String> {
let local_path = Path::new(&config.local_path);
if !local_path.exists() {
create_dir_all(local_path).map_err(|e| format!("Failed to create local path: {}", e))?;
}
let child = Command::new("rclone")
.arg("sync")
.arg(&config.remote_path)
.arg(&config.local_path)
.arg("--no-check-certificate")
.arg("--verbose")
.arg("--rc")
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Failed to start rclone: {}", e))?;
state.sync_processes.lock().unwrap().push(child);
*state.sync_active.lock().unwrap() = true;
Ok(())
}
#[tauri::command]
fn stop_sync(state: tauri::State<AppState>) -> Result<(), String> {
let mut processes = state.sync_processes.lock().unwrap();
for child in processes.iter_mut() {
child.kill().map_err(|e| format!("Failed to kill process: {}", e))?;
}
processes.clear();
*state.sync_active.lock().unwrap() = false;
Ok(())
}
#[tauri::command]
fn get_status(remote_name: String) -> Result<SyncStatus, String> {
let output = Command::new("rclone")
.arg("rc")
.arg("core/stats")
.arg("--json")
.output()
.map_err(|e| format!("Failed to execute rclone rc: {}", e))?;
if !output.status.success() {
return Err(format!("rclone rc failed: {}", String::from_utf8_lossy(&output.stderr)));
}
let json = String::from_utf8_lossy(&output.stdout);
let value: serde_json::Value = serde_json::from_str(&json)
.map_err(|e| format!("Failed to parse rclone status: {}", e))?;
let transferred = value.get("bytes").and_then(|v| v.as_u64()).unwrap_or(0);
let errors = value.get("errors").and_then(|v| v.as_u64()).unwrap_or(0);
let speed = value.get("speed").and_then(|v| v.as_f64()).unwrap_or(0.0);
let status = if errors > 0 {
"Error occurred".to_string()
} else if speed > 0.0 {
"Transferring".to_string()
} else if transferred > 0 {
"Completed".to_string()
} else {
"Initializing".to_string()
};
Ok(SyncStatus {
name: remote_name,
status,
transferred: format_bytes(transferred),
bytes: format!("{}/s", format_bytes(speed as u64)),
errors: errors as usize,
last_updated: chrono::Local::now().format("%H:%M:%S").to_string(),
})
}
fn format_bytes(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}

View file

@ -0,0 +1,106 @@
import React, { useState } from 'react';
import { View, Text, TextInput, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
interface UserAuthFormProps {
// Add any props you need
}
export function UserAuthForm({ }: UserAuthFormProps) {
const [isLoading, setIsLoading] = useState<boolean>(false);
const [email, setEmail] = useState<string>('');
async function onSubmit() {
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
}
return (
<View style={styles.container}>
<View style={styles.form}>
<View style={styles.inputContainer}>
<TextInput
style={styles.input}
placeholder="name@example.com"
keyboardType="email-address"
autoCapitalize="none"
autoComplete="email"
autoCorrect={false}
editable={!isLoading}
value={email}
onChangeText={setEmail}
/>
</View>
<TouchableOpacity
style={styles.button}
onPress={onSubmit}
disabled={isLoading}
>
{isLoading && <ActivityIndicator style={styles.spinner} color="#ffffff" />}
<Text style={styles.buttonText}>Sign In with Email</Text>
</TouchableOpacity>
</View>
<View style={styles.divider}>
<View style={styles.dividerLine} />
<Text style={styles.dividerText}>Or continue with</Text>
<View style={styles.dividerLine} />
</View>
<TouchableOpacity
style={styles.githubButton}
onPress={() => {/* Add GitHub sign in logic */}}
disabled={isLoading}
>
{isLoading ? (
<ActivityIndicator style={styles.spinner} color="#000000" />
) : (
<Text>GitHub Icon</Text> // Replace with actual GitHub icon
)}
<Text style={styles.githubButtonText}>GitHub</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
gap: 20,
},
form: {
gap: 10,
},
inputContainer: {
// Add styles for input container
},
input: {
// Add styles for input
},
button: {
// Add styles for button
},
buttonText: {
// Add styles for button text
},
spinner: {
marginRight: 8,
},
divider: {
flexDirection: 'row',
alignItems: 'center',
},
dividerLine: {
flex: 1,
height: 1,
backgroundColor: '#e0e0e0',
},
dividerText: {
paddingHorizontal: 10,
// Add styles for divider text
},
githubButton: {
// Add styles for GitHub button
},
githubButtonText: {
// Add styles for GitHub button text
},
});

View file

@ -0,0 +1,159 @@
import React, { useState } from 'react';
import { View, Text, Image, StyleSheet, TouchableOpacity, ScrollView, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { ZitadelAuth } from '@zitadel/react';
const AuthenticationScreen = () => {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const handleLogin = async () => {
try {
const auth = new ZitadelAuth({
clientId: 'YOUR_CLIENT_ID',
issuer: 'YOUR_ZITADEL_ISSUER_URL',
redirectUri: 'YOUR_REDIRECT_URI',
scopes: ['openid', 'profile', 'email'],
});
const result = await auth.authorize();
if (result?.accessToken) {
await AsyncStorage.setItem('authToken', result.accessToken);
setIsAuthenticated(true);
Alert.alert('Login Successful', 'You are now authenticated.');
} else {
Alert.alert('Login Failed', 'Unable to retrieve access token.');
}
} catch (error) {
console.error('Login error:', error);
Alert.alert('Login Error', 'An error occurred during login.');
}
};
const handleLogout = async () => {
try {
await AsyncStorage.removeItem('authToken');
setIsAuthenticated(false);
Alert.alert('Logout Successful', 'You are now logged out.');
} catch (error) {
console.error('Logout error:', error);
Alert.alert('Logout Error', 'An error occurred during logout.');
}
};
return (
<SafeAreaView style={styles.container}>
<ScrollView contentContainerStyle={styles.scrollContent}>
<View style={styles.imageContainer}>
</View>
<View style={styles.contentContainer}>
<TouchableOpacity style={styles.loginButton} onPress={isAuthenticated ? handleLogout : handleLogin}>
<Text style={styles.loginButtonText}>{isAuthenticated ? 'Logout' : 'Login'}</Text>
</TouchableOpacity>
<View style={styles.leftPanel}>
<View style={styles.logoContainer}>
{/* Replace with your logo component */}
<Text style={styles.logoText}>Welcome to General Bots Online</Text>
</View>
<View style={styles.quoteContainer}>
<Text style={styles.quoteText}>
"Errar é Humano."
</Text>
<Text style={styles.quoteAuthor}>General Bots</Text>
</View>
</View>
<View style={styles.formContainer}>
<View style={styles.formHeader}>
<Text style={styles.formTitle}>Create an account</Text>
<Text style={styles.formSubtitle}>
Enter your email below to create your account
</Text>
</View>
<Text style={styles.termsText}>
By clicking continue, you agree to our Terms of Service and Privacy Policy.
</Text>
</View>
</View>
</ScrollView>
</SafeAreaView>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
},
scrollContent: {
flexGrow: 1,
},
imageContainer: {
// ... styles for image container
},
image: {
width: '100%',
height: 300,
resizeMode: 'cover',
},
contentContainer: {
flex: 1,
padding: 16,
},
loginButton: {
position: 'absolute',
top: 16,
right: 16,
padding: 8,
},
loginButtonText: {
color: '#000',
},
leftPanel: {
// ... styles for left panel
},
logoContainer: {
flexDirection: 'row',
alignItems: 'center',
},
logoText: {
fontSize: 18,
fontWeight: 'bold',
},
quoteContainer: {
marginTop: 'auto',
},
quoteText: {
fontSize: 16,
marginBottom: 8,
},
quoteAuthor: {
fontSize: 14,
},
formContainer: {
// ... styles for form container
},
formHeader: {
alignItems: 'center',
marginBottom: 16,
},
formTitle: {
fontSize: 24,
fontWeight: 'bold',
},
formSubtitle: {
fontSize: 14,
color: '#666',
},
termsText: {
textAlign: 'center',
fontSize: 12,
color: '#666',
marginTop: 16,
},
});
export default AuthenticationScreen;

View file

@ -0,0 +1,27 @@
import React from 'react';
import { View, Platform } from 'react-native';
import { PersonSelector } from './selector/person-selector';
import { ProjectorView } from './projector/projector-view';
import { ChatWindow } from './chat/chat-window';
import { layoutStyles } from '../styles/layout.styles';
export function ChatLayout() {
return (
<View style={[
layoutStyles.container,
Platform.OS === 'web' && { height: '100vh' }
]}>
<View style={layoutStyles.sidebar}>
<PersonSelector />
</View>
<View style={layoutStyles.mainContent}>
<View style={layoutStyles.projector}>
<ProjectorView />
</View>
<View style={layoutStyles.chatArea}>
<ChatWindow />
</View>
</View>
</View>
);
}

View file

@ -0,0 +1,26 @@
import React from 'react';
import { View, Text, TouchableOpacity } from 'react-native';
import { MoreVertical } from 'lucide-react-native';
import { useChat } from '../../providers/chat-provider';
import { chatStyles } from '../../styles/chat.styles';
export function ChatHeader() {
const { instance } = useChat();
return (
<View style={chatStyles.header}>
<View style={chatStyles.headerContent}>
<Text style={chatStyles.headerTitle}>
{instance?.name || 'Chat'}
</Text>
<Text style={chatStyles.headerSubtitle}>
Online
</Text>
</View>
<TouchableOpacity style={chatStyles.headerButton}>
<MoreVertical color="#00f3ff" size={24} />
</TouchableOpacity>
</View>
);
}

View file

@ -0,0 +1,103 @@
import React from 'react';
import { View, TextInput, TouchableOpacity, Animated } from 'react-native';
import { Send, Paperclip, Mic, Smile } from 'lucide-react-native';
import { EmojiPicker } from '../ui/emoji-picker';
import { useChat } from '../../providers/chat-provider';
import { useSound } from '../../providers/sound-provider';
import { chatStyles } from '../../styles/chat.styles';
export function ChatInput() {
const [message, setMessage] = React.useState('');
const [showEmoji, setShowEmoji] = React.useState(false);
const pulseAnim = React.useRef(new Animated.Value(1)).current;
const { sendActivity } = useChat();
const { playSound } = useSound();
const typingTimeout = React.useRef<NodeJS.Timeout>();
const handleKeyPress = () => {
if (typingTimeout.current) {
clearTimeout(typingTimeout.current);
}
typingTimeout.current = setTimeout(() => {
playSound('typing');
}, 100);
};
const handleSend = () => {
if (!message.trim()) return;
playSound('send');
sendActivity({
type: 'message',
text: message.trim(),
});
setMessage('');
};
const handleEmojiSelect = (emoji: string) => {
playSound('click');
setMessage(prev => prev + emoji);
};
return (
<>
<View style={chatStyles.inputContainer}>
<TouchableOpacity
style={chatStyles.iconButton}
onPress={() => playSound('click')}
>
<Paperclip color="#00f3ff" size={24} />
</TouchableOpacity>
<TouchableOpacity
style={chatStyles.iconButton}
onPress={() => {
playSound('click');
setShowEmoji(true);
}}
>
<Smile color="#00f3ff" size={24} />
</TouchableOpacity>
<TextInput
value={message}
onChangeText={setMessage}
onKeyPress={handleKeyPress}
style={[
chatStyles.input,
{ borderColor: message ? '#00f3ff' : '#333' }
]}
placeholder="Type a message..."
placeholderTextColor="#666"
multiline
/>
{message.trim().length > 0 ? (
<Animated.View style={{ transform: [{ scale: pulseAnim }] }}>
<TouchableOpacity
style={[chatStyles.iconButton, chatStyles.sendButton]}
onPress={handleSend}
>
<Send color="#00f3ff" size={24} />
</TouchableOpacity>
</Animated.View>
) : (
<TouchableOpacity
style={chatStyles.iconButton}
onPress={() => playSound('click')}
>
<Mic color="#00f3ff" size={24} />
</TouchableOpacity>
)}
</View>
<EmojiPicker
visible={showEmoji}
onClose={() => {
playSound('click');
setShowEmoji(false);
}}
onEmojiSelect={handleEmojiSelect}
/>
</>
);
}

View file

@ -0,0 +1,33 @@
import React from 'react';
import { View } from 'react-native';
import { MessageList } from './message-list';
import { ChatInput } from './chat-input';
import { ChatHeader } from './chat-header';
import { useChat } from '../../providers/chat-provider';
import { Message } from '../../types';
import { chatStyles } from '../../styles/chat.styles';
export function ChatWindow() {
const { line } = useChat();
const [messages, setMessages] = React.useState<Message[]>([]);
React.useEffect(() => {
if (!line) return;
const subscription = line.activity$.subscribe(activity => {
if (activity.type === 'message') {
setMessages(prev => [...prev, activity as Message]);
}
});
return () => subscription.unsubscribe();
}, [line]);
return (
<View style={chatStyles.window}>
<ChatHeader />
<MessageList messages={messages} />
<ChatInput />
</View>
);
}

View file

@ -0,0 +1,53 @@
import React from 'react';
import { ScrollView, View, Text } from 'react-native';
import { useChat } from '../../providers/chat-provider';
import { useSound } from '../../providers/sound-provider';
import { Message } from '../../types';
import { chatStyles } from '../../styles/chat.styles';
interface MessageListProps {
messages: Message[];
}
export function MessageList({ messages }: MessageListProps) {
const scrollViewRef = React.useRef<ScrollView>(null);
const { user } = useChat();
const { playSound } = useSound();
const prevMessagesLength = React.useRef(messages.length);
React.useEffect(() => {
if (messages.length > prevMessagesLength.current) {
const lastMessage = messages[messages.length - 1];
if (lastMessage.from.id !== user.id) {
playSound('receive');
}
scrollViewRef.current?.scrollToEnd({ animated: true });
}
prevMessagesLength.current = messages.length;
}, [messages]);
return (
<ScrollView
ref={scrollViewRef}
style={chatStyles.messageList}
contentContainerStyle={chatStyles.messageListContent}
>
{messages.map((message, index) => (
<View
key={`${message.id}-${index}`}
style={[
chatStyles.messageContainer,
message.from.id === user.id
? chatStyles.userMessage
: chatStyles.botMessage
]}
>
<Text style={chatStyles.messageText}>{message.text}</Text>
<Text style={chatStyles.messageTime}>
{new Date(message.timestamp).toLocaleTimeString()}
</Text>
</View>
))}
</ScrollView>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { View, Image } from 'react-native';
import { projectorStyles } from '../../styles/projector.styles';
interface ImageViewerProps {
url: string;
}
export function ImageViewer({ url }: ImageViewerProps) {
return (
<View style={projectorStyles.imageContainer}>
<Image
source={{ uri: url }}
style={projectorStyles.image}
resizeMode="contain"
/>
</View>
);
}

View file

@ -0,0 +1,18 @@
import React from 'react';
import { ScrollView } from 'react-native';
import Markdown from 'react-native-markdown-display';
import { projectorStyles } from '../../styles/projector.styles';
interface MarkdownViewerProps {
content: string;
}
export function MarkdownViewer({ content }: MarkdownViewerProps) {
return (
<ScrollView style={projectorStyles.markdownContainer}>
<Markdown style={projectorStyles.markdown}>
{content}
</Markdown>
</ScrollView>
);
}

View file

@ -0,0 +1,45 @@
import React from 'react';
import { View } from 'react-native';
import { VideoPlayer } from './video-player';
import { ImageViewer } from './image-viewer';
import { MarkdownViewer } from './markdown-viewer';
import { useChat } from '../../providers/chat-provider';
import { projectorStyles } from '../../styles/projector.styles';
export function ProjectorView() {
const { line } = useChat();
const [content, setContent] = React.useState<any>(null);
React.useEffect(() => {
if (!line) return;
const subscription = line.activity$
.filter(activity => activity.type === 'event' && activity.name === 'project')
.subscribe(activity => {
setContent(activity.value);
});
return () => subscription.unsubscribe();
}, [line]);
const renderContent = () => {
if (!content) return null;
switch (content.type) {
case 'video':
return <VideoPlayer url={content.url} />;
case 'image':
return <ImageViewer url={content.url} />;
case 'markdown':
return <MarkdownViewer content={content.text} />;
default:
return null;
}
};
return (
<View style={projectorStyles.container}>
{renderContent()}
</View>
);
}

View file

@ -0,0 +1,19 @@
import React from 'react';
import { View, Image } from 'react-native';
import { projectorStyles } from '../../styles/projector.styles';
interface ImageViewerProps {
url: string;
}
export function VideoViewer({ url }: ImageViewerProps) {
return (
<View style={projectorStyles.imageContainer}>
<iframe
src={url}
/>
</View>
);
}

View file

@ -0,0 +1,50 @@
import React from 'react';
import { View, Text, Image, TextInput, ScrollView, TouchableOpacity } from 'react-native';
import { Search } from 'lucide-react-native';
import { useChat } from '../../providers/chat-provider';
import { selectorStyles } from '../../styles/selector.styles';
export function PersonSelector() {
const [search, setSearch] = React.useState('');
const { instance } = useChat();
return (
<View style={selectorStyles.container}>
<View style={selectorStyles.header}>
<Image
source={{ uri: instance?.logo }}
style={selectorStyles.logo}
resizeMode="contain"
/>
</View>
<View style={selectorStyles.searchContainer}>
<Search size={20} color="#00f3ff" />
<TextInput
value={search}
onChangeText={setSearch}
placeholder="Search conversations..."
placeholderTextColor="#666"
style={selectorStyles.searchInput}
/>
</View>
<ScrollView style={selectorStyles.list}>
{['FAQ', 'Support', 'Sales'].map((item) => (
<TouchableOpacity
key={item}
style={selectorStyles.item}
>
<View style={selectorStyles.avatar}>
<Text style={selectorStyles.avatarText}>{item[0]}</Text>
</View>
<View style={selectorStyles.itemContent}>
<Text style={selectorStyles.itemTitle}>{item}</Text>
<Text style={selectorStyles.itemSubtitle}>Start a conversation</Text>
</View>
</TouchableOpacity>
))}
</ScrollView>
</View>
);
}

View file

@ -0,0 +1,43 @@
import React, { useEffect, useState } from 'react';
import { View, Text } from 'react-native';
import { soundAssets } from '../../../public/sounds/manifest';
import { cacheAssets } from '../lib/asset-loader';
export function SoundInitializer({ children }: { children: React.ReactNode }) {
const [isReady, setIsReady] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const initializeSounds = async () => {
try {
await cacheAssets(Object.values(soundAssets));
setIsReady(true);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to initialize sounds');
}
};
//initializeSounds();
setIsReady(true);
}, []);
if (error) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text style={{ color: 'red' }}>Error: {error}</Text>
</View>
);
}
if (!isReady) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<Text>Loading sounds...</Text>
</View>
);
}
return <>{children}</>;
}

View file

@ -0,0 +1,51 @@
import React from 'react';
import { View, Text, ScrollView, TouchableOpacity, Modal } from 'react-native';
import { X } from 'lucide-react-native';
import { emojiStyles } from '../../styles/ui.styles';
const EMOJI_CATEGORIES = {
"😀 🎮": ["😀", "😎", "🤖", "👾", "🎮", "✨", "🚀", "💫"],
"🌟 💫": ["⭐", "🌟", "💫", "✨", "⚡", "💥", "🔥", "🌈"],
"🤖 🎯": ["🤖", "🎯", "🎲", "🎮", "🕹️", "👾", "💻", "⌨️"]
};
export function EmojiPicker({ visible, onClose, onEmojiSelect }) {
return (
<Modal
visible={visible}
transparent
animationType="slide"
>
<View style={emojiStyles.container}>
<View style={emojiStyles.header}>
<Text style={emojiStyles.title}>Select Emoji</Text>
<TouchableOpacity onPress={onClose}>
<X color="#00f3ff" size={24} />
</TouchableOpacity>
</View>
<ScrollView style={emojiStyles.content}>
{Object.entries(EMOJI_CATEGORIES).map(([category, emojis]) => (
<View key={category} style={emojiStyles.category}>
<Text style={emojiStyles.categoryTitle}>{category}</Text>
<View style={emojiStyles.emojiGrid}>
{emojis.map(emoji => (
<TouchableOpacity
key={emoji}
style={emojiStyles.emojiButton}
onPress={() => {
onEmojiSelect(emoji);
onClose();
}}
>
<Text style={emojiStyles.emoji}>{emoji}</Text>
</TouchableOpacity>
))}
</View>
</View>
))}
</ScrollView>
</View>
</Modal>
);
}

View file

@ -0,0 +1,49 @@
import React from 'react';
import { View, Text, Switch, TouchableOpacity, ScrollView } from 'react-native';
import { Moon, Sun, Volume2, VolumeX, Zap, Settings } from 'lucide-react-native';
import { useSound } from '../../providers/sound-provider';
import { settingsStyles } from '../../styles/ui.styles';
export function SettingsPanel() {
const [theme, setTheme] = React.useState('dark');
const [sound, setSound] = React.useState(true);
const [powerMode, setPowerMode] = React.useState(false);
const { setEnabled, playSound } = useSound();
const handleSoundToggle = (value: boolean) => {
setSound(value);
setEnabled(value);
if (value) {
playSound('success');
}
};
const handleThemeChange = () => {
playSound('click');
setTheme(theme === 'dark' ? 'light' : 'dark');
};
const handlePowerMode = (value: boolean) => {
playSound(value ? 'success' : 'click');
setPowerMode(value);
};
return (
// ... rest of the settings panel code ...
<View style={settingsStyles.option}>
{sound ? (
<Volume2 color="#00f3ff" size={20} />
) : (
<VolumeX color="#666" size={20} />
)}
<Text style={settingsStyles.optionText}>Sound Effects</Text>
<Switch
value={sound}
onValueChange={handleSoundToggle}
trackColor={{ false: '#333', true: '#00f3ff44' }}
thumbColor={sound ? '#00f3ff' : '#666'}
/>
</View>
// ... rest of the settings panel code ...
);
}

17
src/chat/index.tsx Normal file
View file

@ -0,0 +1,17 @@
import React from 'react';
import { ChatProvider } from './providers/chat-provider';
import { ChatLayout } from './components/chat-layout';
import { SoundInitializer } from './components/sound-initializer';
import { SoundProvider } from './providers/sound-provider';
export function Chat() {
return (
<SoundInitializer>
<SoundProvider>
<ChatProvider>
<ChatLayout />
</ChatProvider>
</SoundProvider>
</SoundInitializer>
);
}

View file

@ -0,0 +1,39 @@
import { Asset } from 'expo-asset';
import * as FileSystem from 'expo-file-system';
export async function ensureAssetLoaded(assetPath: string): Promise<string> {
try {
const asset = Asset.fromModule(assetPath);
if (!asset.localUri) {
await asset.downloadAsync();
}
return asset.localUri;
} catch (error) {
console.error('Failed to load asset:', error);
throw error;
}
}
export async function cacheAssets(assets: string[]): Promise<void> {
try {
const cacheDirectory = `${FileSystem.cacheDirectory}sounds/`;
await FileSystem.makeDirectoryAsync(cacheDirectory, { intermediates: true });
await Promise.all(
assets.map(async (asset) => {
const assetName = asset.split('/').pop();
const cachedPath = `${cacheDirectory}${assetName}`;
const fileInfo = await FileSystem.getInfoAsync(cachedPath);
if (!fileInfo.exists) {
await FileSystem.copyAsync({
from: asset,
to: cachedPath,
});
}
})
);
} catch (error) {
console.error('Failed to cache assets:', error);
}
}

14
src/chat/lib/utils.ts Normal file
View file

@ -0,0 +1,14 @@
export function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: '2-digit',
minute: '2-digit',
}).format(date);
}
export function cn(...classes: string[]): string {
return classes.filter(Boolean).join(' ');
}
export function generateId(): string {
return Math.random().toString(36).slice(2);
}

View file

@ -0,0 +1,105 @@
import React, { useState } from 'react';
import { DirectLine } from 'botframework-directlinejs';
import { ChatInstance, User } from '../types';
import { v4 as uuidv4 } from 'uuid';
interface ChatContextType {
line: DirectLine | null;
user: User;
instance: ChatInstance | null;
sendActivity: (activity: any) => void;
selectedVoice: any;
setVoice: (voice: any) => void;
}
const generateUserId = () => {
return 'usergb@gb';
};
export const ChatContext = React.createContext<ChatContextType | undefined>(undefined);
export function ChatProvider({ children }: { children: React.ReactNode }) {
const [line, setLine] = React.useState<DirectLine | null>(null);
const [instance, setInstance] = React.useState<ChatInstance | null>(null);
const [selectedVoice, setSelectedVoice] = useState(null);
const [user] = React.useState<User>(() => ({
id: `user_${Math.random().toString(36).slice(2)}`,
name: 'You'
}));
React.useEffect(() => {
initializeChat();
}, []);
const initializeChat = async () => {
try {
var botId = window.location.href.split('/')[3];
if (botId.indexOf('#') !== -1) {
botId = botId.split('#')[0];
}
if (!botId || botId === '') {
botId = '[default]';
}
const response = await fetch(
'http://localhost:4242/instances/' + botId,
)
const data = await response.json();
const userId = generateUserId();
const directLine = data.webchatToken
? new DirectLine({
token: data.token,
webSocket: true
})
: new DirectLine({
domain: data.domain,
secret: null,
token: null,
webSocket: false
});
directLine.setUserId(userId);
setLine(directLine);
setInstance(data.instance);
console.info (`DirectLine for user:` + userId);
} catch (error) {
console.error('Failed to initialize chat:', error);
}
};
const sendActivity = (activity: any) => {
line?.postActivity({
...activity,
from: user,
timestamp: new Date().toISOString()
}).subscribe();
};
const setVoice = (voice: any) => {
setSelectedVoice(voice);
};
const contextValue: ChatContextType = {
line,
user,
instance,
sendActivity,
selectedVoice,
setVoice
};
return (
<ChatContext.Provider value={contextValue}>
{children}
</ChatContext.Provider>
);
}
export function useChat() {
const context = React.useContext(ChatContext);
if (!context) {
throw new Error('useChat must be used within ChatProvider');
}
return context;
}

View file

@ -0,0 +1,32 @@
import React from 'react';
interface SoundContextType {
playSound: (sound: string) => void;
setEnabled: (enabled: boolean) => void;
}
const SoundContext = React.createContext<SoundContextType | undefined>(undefined);
export function SoundProvider({ children }: { children: React.ReactNode }) {
const playSound = React.useCallback((sound: string) => {
// soundManager.play(sound as any);
}, []);
const setEnabled = React.useCallback((enabled: boolean) => {
// soundManager.setEnabled(enabled);
}, []);
return (
<SoundContext.Provider value={{ playSound, setEnabled }}>
{children}
</SoundContext.Provider>
);
}
export function useSound() {
const context = React.useContext(SoundContext);
if (!context) {
throw new Error('useSound must be used within SoundProvider');
}
return context;
}

View file

@ -0,0 +1,120 @@
import { StyleSheet } from 'react-native';
export const audioStyles = StyleSheet.create({
volumeContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
backgroundColor: '#1a1a1a',
borderRadius: 12,
borderWidth: 1,
borderColor: '#333',
},
volumeButton: {
padding: 8,
},
sliderContainer: {
flex: 1,
marginLeft: 16,
},
slider: {
flex: 1,
height: 40,
},
visualizerContainer: {
flexDirection: 'row',
height: 40,
alignItems: 'center',
justifyContent: 'space-between',
paddingHorizontal: 8,
},
visualizerBar: {
width: 3,
height: 20,
backgroundColor: '#00f3ff',
borderRadius: 2,
marginHorizontal: 1,
},
modal: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.95)',
margin: 20,
marginTop: 100,
borderRadius: 20,
borderWidth: 1,
borderColor: '#00f3ff',
shadowColor: '#00f3ff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 10,
},
voiceList: {
flex: 1,
padding: 16,
},
voiceOption: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
backgroundColor: '#1a1a1a',
borderRadius: 12,
marginBottom: 8,
borderWidth: 1,
borderColor: '#333',
},
selectedVoice: {
borderColor: '#00f3ff',
backgroundColor: '#00f3ff11',
},
voiceInfo: {
flex: 1,
},
voiceName: {
color: '#ffffff',
fontSize: 16,
fontWeight: 'bold',
},
voiceAccent: {
color: '#666',
fontSize: 14,
marginTop: 4,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#333',
},
title: {
color: '#00f3ff',
fontSize: 18,
fontWeight: 'bold',
},
closeButton: {
color: '#00f3ff',
fontSize: 24,
fontWeight: 'bold',
},
trigger: {
flexDirection: 'row',
alignItems: 'center',
padding: 8,
backgroundColor: '#1a1a1a',
borderRadius: 8,
borderWidth: 1,
borderColor: '#333',
},
triggerText: {
color: '#ffffff',
marginLeft: 8,
fontSize: 14,
},
});
export const voiceStyles = StyleSheet.create({
// ... copy from audioStyles the modal-related styles ...
// Add voice-specific styles here
});

View file

@ -0,0 +1,91 @@
import { StyleSheet } from 'react-native';
export const chatStyles = StyleSheet.create({
window: {
flex: 1,
backgroundColor: '#111111',
},
messageList: {
flex: 1,
padding: 16,
},
messageListContent: {
paddingBottom: 16,
},
messageContainer: {
maxWidth: '70%',
marginVertical: 4,
padding: 12,
borderRadius: 12,
},
userMessage: {
alignSelf: 'flex-end',
backgroundColor: '#00f3ff22',
borderColor: '#00f3ff',
borderWidth: 1,
},
botMessage: {
alignSelf: 'flex-start',
backgroundColor: '#bf00ff22',
borderColor: '#bf00ff',
borderWidth: 1,
},
messageText: {
color: '#ffffff',
fontSize: 16,
},
messageTime: {
color: '#666666',
fontSize: 12,
marginTop: 4,
},
inputContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderTopWidth: 1,
borderTopColor: '#1a1a1a',
},
input: {
flex: 1,
marginHorizontal: 12,
padding: 12,
backgroundColor: '#1a1a1a',
borderRadius: 24,
color: '#ffffff',
maxHeight: 100,
},
iconButton: {
padding: 8,
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#1a1a1a',
},
headerContent: {
flex: 1,
},
headerTitle: {
color: '#ffffff',
fontSize: 18,
fontWeight: 'bold',
},
headerSubtitle: {
color: '#00f3ff',
fontSize: 14,
marginTop: 2,
},
headerButton: {
padding: 8,
},
sendButton: {
backgroundColor: '#00f3ff22',
borderRadius: 20,
padding: 10,
borderWidth: 1,
borderColor: '#00f3ff',
}
});

View file

@ -0,0 +1,27 @@
// layout.styles.ts
import { Colors } from '../../../constants/Colors';
import { StyleSheet } from 'react-native';
export const layoutStyles = StyleSheet.create({
container: {
flex: 1,
flexDirection: 'row',
backgroundColor: Colors.dark.background,
},
sidebar: {
width: 300,
borderRightWidth: 1,
borderRightColor: Colors.dark.icon,
},
mainContent: {
flex: 1,
flexDirection: 'row',
},
projector: {
width: '40%',
borderRightWidth: 1,
borderRightColor: Colors.dark.icon,
},
chatArea: {
flex: 1,
},
});

View file

@ -0,0 +1,50 @@
// projector.styles.ts
import { Colors } from '../../../constants/Colors';
import { StyleSheet } from 'react-native';
export const projectorStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.dark.background,
padding: 16,
},
videoContainer: {
aspectRatio: 16/9,
backgroundColor: Colors.dark.background,
borderRadius: 8,
overflow: 'hidden',
},
imageContainer: {
flex: 1,
backgroundColor: Colors.dark.background,
borderRadius: 8,
overflow: 'hidden',
},
markdownContainer: {
flex: 1,
padding: 16,
backgroundColor: Colors.dark.background,
borderRadius: 8,
},
body: {
color: Colors.dark.text,
fontSize: 16,
},
heading1: {
color: Colors.dark.tint,
fontSize: 24,
marginBottom: 16,
},
heading2: {
color: Colors.dark.tint,
fontSize: 20,
marginBottom: 12,
},
link: {
color: Colors.dark.tint,
},
code_block: {
backgroundColor: Colors.dark.background,
padding: 12,
borderRadius: 4,
}
});

View file

@ -0,0 +1,69 @@
// selector.styles.ts
import { StyleSheet } from 'react-native';
import { Colors } from '../../../constants/Colors';
export const selectorStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: Colors.dark.background,
},
header: {
padding: 16,
borderBottomWidth: 1,
borderBottomColor: Colors.dark.icon,
},
logo: {
width: 150,
height: 50,
},
searchContainer: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
borderBottomWidth: 1,
borderBottomColor: Colors.dark.icon,
},
searchInput: {
flex: 1,
marginLeft: 8,
color: Colors.dark.text,
fontSize: 16,
},
list: {
flex: 1,
},
item: {
flexDirection: 'row',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: Colors.dark.icon,
},
avatar: {
width: 48,
height: 48,
borderRadius: 24,
backgroundColor: Colors.dark.background,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
borderColor: Colors.dark.tint,
},
avatarText: {
color: Colors.dark.tint,
fontSize: 20,
},
itemContent: {
marginLeft: 12,
flex: 1,
},
itemTitle: {
color: Colors.dark.text,
fontSize: 16,
fontWeight: 'bold',
},
itemSubtitle: {
color: Colors.dark.icon,
fontSize: 14,
marginTop: 4,
},
});

View file

@ -0,0 +1,150 @@
import { StyleSheet } from 'react-native';
export const emojiStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'rgba(0,0,0,0.95)',
margin: 20,
marginTop: 100,
borderRadius: 20,
borderWidth: 1,
borderColor: '#00f3ff',
shadowColor: '#00f3ff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.5,
shadowRadius: 10,
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#00f3ff33',
},
title: {
color: '#00f3ff',
fontSize: 18,
fontWeight: 'bold',
},
content: {
flex: 1,
padding: 16,
},
category: {
marginBottom: 24,
},
categoryTitle: {
color: '#bf00ff',
fontSize: 16,
marginBottom: 12,
},
emojiGrid: {
flexDirection: 'row',
flexWrap: 'wrap',
},
emojiButton: {
width: '12.5%',
aspectRatio: 1,
alignItems: 'center',
justifyContent: 'center',
borderRadius: 8,
},
emoji: {
fontSize: 24,
},
});
export const settingsStyles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#111111',
},
header: {
flexDirection: 'row',
alignItems: 'center',
padding: 20,
borderBottomWidth: 1,
borderBottomColor: '#00f3ff33',
},
title: {
color: '#00f3ff',
fontSize: 20,
fontWeight: 'bold',
marginLeft: 12,
},
section: {
padding: 20,
},
sectionTitle: {
color: '#bf00ff',
fontSize: 16,
marginBottom: 16,
},
option: {
flexDirection: 'row',
alignItems: 'center',
padding: 12,
marginBottom: 12,
backgroundColor: '#1a1a1a',
borderRadius: 12,
borderWidth: 1,
borderColor: '#333',
},
optionText: {
color: '#ffffff',
fontSize: 16,
marginLeft: 12,
flex: 1,
},
activeIndicator: {
width: 8,
height: 8,
borderRadius: 4,
marginLeft: 8,
},
effectPreview: {
padding: 20,
},
previewTitle: {
color: '#00f3ff',
fontSize: 16,
marginBottom: 12,
},
previewContent: {
padding: 20,
backgroundColor: '#1a1a1a',
borderRadius: 12,
alignItems: 'center',
borderWidth: 1,
borderColor: '#00f3ff33',
},
previewText: {
color: '#00f3ff',
fontSize: 24,
fontWeight: 'bold',
},
});
// Add animation helpers
export const pulseAnimation = {
0: {
opacity: 1,
scale: 1,
},
0.5: {
opacity: 0.7,
scale: 1.05,
},
1: {
opacity: 1,
scale: 1,
},
};
export const neonGlow = {
shadowColor: '#00f3ff',
shadowOffset: { width: 0, height: 0 },
shadowOpacity: 0.8,
shadowRadius: 15,
};

28
src/chat/types/index.ts Normal file
View file

@ -0,0 +1,28 @@
export interface Message {
id: string;
type: 'message' | 'event';
text?: string;
timestamp: string;
from: User;
attachments?: Attachment[];
}
export interface User {
id: string;
name: string;
avatar?: string;
}
export interface Attachment {
type: 'image' | 'video' | 'document';
url: string;
thumbnailUrl?: string;
}
export interface ChatInstance {
id: string;
name: string;
logo?: string;
botId: string;
webchatToken: string;
}

View file

@ -0,0 +1,63 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import DateTimePicker from '@react-native-community/datetimepicker';
export function CalendarDateRangePicker() {
const [dateRange, setDateRange] = useState({ startDate: new Date(), endDate: new Date() });
const [showStartPicker, setShowStartPicker] = useState(false);
const [showEndPicker, setShowEndPicker] = useState(false);
const onChangeStart = (event, selectedDate) => {
setShowStartPicker(false);
if (selectedDate) {
setDateRange(prev => ({ ...prev, startDate: selectedDate }));
}
};
const onChangeEnd = (event, selectedDate) => {
setShowEndPicker(false);
if (selectedDate) {
setDateRange(prev => ({ ...prev, endDate: selectedDate }));
}
};
return (
<View style={styles.container}>
<TouchableOpacity onPress={() => setShowStartPicker(true)} style={styles.button}>
<Text>Start: {dateRange.startDate.toLocaleDateString()}</Text>
</TouchableOpacity>
<TouchableOpacity onPress={() => setShowEndPicker(true)} style={styles.button}>
<Text>End: {dateRange.endDate.toLocaleDateString()}</Text>
</TouchableOpacity>
{showStartPicker && (
<DateTimePicker
value={dateRange.startDate}
mode="date"
display="default"
onChange={onChangeStart}
/>
)}
{showEndPicker && (
<DateTimePicker
value={dateRange.endDate}
mode="date"
display="default"
onChange={onChangeEnd}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-between',
marginVertical: 10,
},
button: {
padding: 10,
backgroundColor: '#f0f0f0',
borderRadius: 5,
},
});

View file

@ -0,0 +1,36 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
export function MainNav() {
return (
<View style={styles.container}>
<TouchableOpacity style={styles.navItem}>
<Text style={styles.navText}>Overview</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navItem}>
<Text style={styles.navText}>Customers</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navItem}>
<Text style={styles.navText}>Products</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.navItem}>
<Text style={styles.navText}>Settings</Text>
</TouchableOpacity>
</View>
);
}
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
justifyContent: 'space-around',
alignItems: 'center',
paddingVertical: 10,
},
navItem: {
padding: 10,
},
navText: {
fontSize: 16,
},
});

View file

@ -0,0 +1,60 @@
import React from 'react';
import { View, Dimensions, StyleSheet } from 'react-native';
import { BarChart } from 'react-native-chart-kit';
const data = {
labels: ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"],
datasets: [
{
data: [
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100,
Math.random() * 100
]
}
]
};
export function Overview() {
return (
<View style={styles.container}>
<BarChart
data={data}
width={Dimensions.get("window").width - 40}
height={220}
yAxisLabel="$"
chartConfig={{
backgroundColor: "#ffffff",
backgroundGradientFrom: "#ffffff",
backgroundGradientTo: "#ffffff",
decimalPlaces: 2,
color: (opacity = 1) => `rgba(0, 0, 0, ${opacity})`,
style: {
borderRadius: 16
}
}}
style={{
marginVertical: 8,
borderRadius: 16
}}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
alignItems: 'center',
justifyContent: 'center',
marginTop: 10,
},
});

View file

@ -0,0 +1,31 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Avatar, ListItem } from 'react-native-elements';
const salesData = [
{ name: "Olivia Martin", email: "olivia.martin@email.com", amount: "+$1,999.00" },
{ name: "Jackson Lee", email: "jackson.lee@email.com", amount: "+$39.00" },
{ name: "Isabella Nguyen", email: "isabella.nguyen@email.com", amount: "+$299.00" },
{ name: "William Kim", email: "will@email.com", amount: "+$99.00" },
{ name: "Sofia Davis", email: "sofia.davis@email.com", amount: "+$39.00" },
];
export function RecentSales() {
return (
<View>
{salesData.map((item, index) => (
<ListItem key={index} bottomDivider>
<Avatar
rounded
source={{ uri: `https://i.pravatar.cc/150?u=${item.email}` }}
/>
<ListItem.Content>
<ListItem.Title>{item.name}</ListItem.Title>
<ListItem.Subtitle>{item.email}</ListItem.Subtitle>
</ListItem.Content>
<Text>{item.amount}</Text>
</ListItem>
))}
</View>
);
}

View file

@ -0,0 +1,31 @@
import React from 'react';
import { View, StyleSheet } from 'react-native';
import { Input } from 'react-native-elements';
export function Search() {
return (
<View style={styles.container}>
<Input
placeholder="Search..."
containerStyle={styles.inputContainer}
inputContainerStyle={styles.inputInnerContainer}
/>
</View>
);
}
const styles = StyleSheet.create({
container: {
width: '100%',
maxWidth: 300,
},
inputContainer: {
paddingHorizontal: 0,
},
inputInnerContainer: {
borderBottomWidth: 0,
backgroundColor: '#f0f0f0',
borderRadius: 20,
paddingHorizontal: 15,
},
});

View file

@ -0,0 +1,80 @@
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, Modal, StyleSheet } from 'react-native';
import { Avatar, Button, Input } from 'react-native-elements';
const groups = [
{
label: "Personal Account",
teams: [
{ label: "Alicia Koch", value: "personal" },
],
},
{
label: "Teams",
teams: [
{ label: "Acme Inc.", value: "acme-inc" },
{ label: "Monsters Inc.", value: "monsters" },
],
},
];
export function TeamSwitcher() {
const [open, setOpen] = useState(false);
const [showNewTeamDialog, setShowNewTeamDialog] = useState(false);
const [selectedTeam, setSelectedTeam] = useState(groups[0].teams[0]);
return (
<View>
<TouchableOpacity onPress={() => setOpen(true)}>
<Avatar
rounded
source={{ uri: `https://avatar.vercel.sh/${selectedTeam.value}.png` }}
/>
<Text>{selectedTeam.label}</Text>
</TouchableOpacity>
<Modal visible={open} animationType="slide">
<View style={styles.modal}>
<Input placeholder="Search team..." />
{groups.map((group) => (
<View key={group.label}>
<Text style={styles.groupLabel}>{group.label}</Text>
{group.teams.map((team) => (
<TouchableOpacity
key={team.value}
onPress={() => {
setSelectedTeam(team);
setOpen(false);
}}
>
<Text>{team.label}</Text>
</TouchableOpacity>
))}
</View>
))}
<Button title="Create Team" onPress={() => setShowNewTeamDialog(true)} />
<Button title="Close" onPress={() => setOpen(false)} />
</View>
</Modal>
<Modal visible={showNewTeamDialog} animationType="slide">
<View style={styles.modal}>
<Text>Create team</Text>
<Input placeholder="Team name" />
<Button title="Create" onPress={() => setShowNewTeamDialog(false)} />
<Button title="Cancel" onPress={() => setShowNewTeamDialog(false)} />
</View>
</Modal>
</View>
);
}
const styles = StyleSheet.create({
modal: {
flex: 1,
justifyContent: 'center',
padding: 20,
},
groupLabel: {
fontWeight: 'bold',
marginTop: 10,
},
});

View file

@ -0,0 +1,57 @@
import React from 'react';
import { View, Text, TouchableOpacity, StyleSheet } from 'react-native';
import { Avatar, Overlay } from 'react-native-elements';
export function UserNav() {
const [visible, setVisible] = React.useState(false);
const toggleOverlay = () => {
setVisible(!visible);
};
return (
<View>
<TouchableOpacity onPress={toggleOverlay}>
<Avatar
rounded
source={{ uri: 'https://i.pravatar.cc/150?u=a042581f4e29026704d' }}
/>
</TouchableOpacity>
<Overlay isVisible={visible} onBackdropPress={toggleOverlay}>
<View>
<Text style={styles.overlayText}>shadcn</Text>
<Text style={styles.overlaySubtext}>m@example.com</Text>
<TouchableOpacity style={styles.overlayButton}>
<Text>Profile</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.overlayButton}>
<Text>Billing</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.overlayButton}>
<Text>Settings</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.overlayButton}>
<Text>New Team</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.overlayButton}>
<Text>Log out</Text>
</TouchableOpacity>
</View>
</Overlay>
</View>
);
}
const styles = StyleSheet.create({
overlayText: {
fontSize: 18,
fontWeight: 'bold',
},
overlaySubtext: {
fontSize: 14,
color: 'gray',
},
overlayButton: {
paddingVertical: 10,
},
});

81
src/dashboard/index.tsx Normal file
View file

@ -0,0 +1,81 @@
import React from 'react';
import { View, Text, ScrollView, StyleSheet } from 'react-native';
import { Button, Card } from 'react-native-elements';
import { TeamSwitcher } from './components/TeamSwitcher';
import { MainNav } from './components/MainNav';
import { Search } from './components/Search';
import { UserNav } from './components/UserNav';
import { CalendarDateRangePicker } from './components/DateRangePicker';
import { Overview } from './components/Overview';
import { RecentSales } from './components/RecentSales';
export default function DashboardScreen() {
return (
<ScrollView style={styles.container}>
<View style={styles.header}>
<TeamSwitcher />
<MainNav />
<View style={styles.rightHeader}>
<Search />
<UserNav />
</View>
</View>
<View style={styles.content}>
<View style={styles.titleRow}>
<Text style={styles.title}>Dashboard</Text>
<View style={styles.actions}>
<CalendarDateRangePicker />
<Button title="Download" />
</View>
</View>
<View style={styles.cards}>
<Card>
<Card.Title>Total Revenue</Card.Title>
<Text style={styles.cardValue}>$45,231.89</Text>
<Text style={styles.cardSubtext}>+20.1% from last month</Text>
</Card>
<Card>
<Card.Title>Subscriptions</Card.Title>
<Text style={styles.cardValue}>+2350</Text>
<Text style={styles.cardSubtext}>+180.1% from last month</Text>
</Card>
<Card>
<Card.Title>Sales</Card.Title>
<Text style={styles.cardValue}>+12,234</Text>
<Text style={styles.cardSubtext}>+19% from last month</Text>
</Card>
<Card>
<Card.Title>Active Now</Card.Title>
<Text style={styles.cardValue}>+573</Text>
<Text style={styles.cardSubtext}>+201 since last hour</Text>
</Card>
</View>
<View style={styles.charts}>
<Card>
<Card.Title>Overview</Card.Title>
<Overview />
</Card>
<Card>
<Card.Title>Recent Sales</Card.Title>
<Text>You made 265 sales this month.</Text>
<RecentSales />
</Card>
</View>
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor:'white' },
header: { flexDirection: 'row', padding: 16, alignItems: 'center' },
rightHeader: { flexDirection: 'row', marginLeft: 'auto' },
content: { padding: 16 },
titleRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
title: { fontSize: 24, fontWeight: 'bold' },
actions: { flexDirection: 'row' },
cards: { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between' },
charts: { marginTop: 16 },
cardValue: { fontSize: 20, fontWeight: 'bold' },
cardSubtext: { fontSize: 12, color: 'gray' },
});

View file

@ -0,0 +1,47 @@
import { useState, useEffect } from 'react';
import { invoke } from '@tauri-apps/api/tauri';
interface FileItem {
name: string;
path: string;
is_dir: boolean;
}
const FileBrowser = ({ path }: { path: string }) => {
const [files, setFiles] = useState<FileItem[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const loadFiles = async () => {
setLoading(true);
try {
const result = await invoke<FileItem[]>('list_files', { path });
setFiles(result);
} catch (error) {
console.error('Error listing files:', error);
} finally {
setLoading(false);
}
};
loadFiles();
}, [path]);
return (
<div className="flex-1 p-4">
<h3 className="text-lg font-semibold mb-4">File Browser: {path || 'Root'}</h3>
{loading ? (
<p>Loading...</p>
) : (
<ul className="space-y-1">
{files.map((file) => (
<li key={file.path} className="p-2 hover:bg-gray-100 rounded">
{file.is_dir ? '📁' : '📄'} {file.name}
</li>
))}
</ul>
)}
</div>
);
};
export default FileBrowser;

View file

@ -0,0 +1,84 @@
import { invoke } from '@tauri-apps/api/tauri';
import { open } from '@tauri-apps/api/dialog';
import { writeBinaryFile, BaseDirectory } from '@tauri-apps/api/fs';
import { useState } from 'react';
interface FileOperationsProps {
currentPath: string;
onRefresh: () => void;
}
const FileOperations = ({ currentPath, onRefresh }: FileOperationsProps) => {
const [uploadProgress, setUploadProgress] = useState(0);
const handleUpload = async () => {
try {
const selected = await open({
multiple: false,
directory: false,
});
if (selected) {
const filePath = Array.isArray(selected) ? selected[0] : selected;
await invoke('upload_file', {
srcPath: filePath,
destPath: currentPath,
onProgress: (progress: number) => setUploadProgress(progress)
});
onRefresh();
alert('File uploaded successfully!');
}
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed!');
} finally {
setUploadProgress(0);
}
};
const createFolder = async () => {
const folderName = prompt('Enter folder name:');
if (folderName) {
try {
await invoke('create_folder', {
path: currentPath,
name: folderName
});
onRefresh();
} catch (error) {
console.error('Error creating folder:', error);
alert('Failed to create folder');
}
}
};
return (
<div className="p-4 border-t border-gray-300 space-y-3">
<h3 className="text-lg font-semibold">File Operations</h3>
<div className="flex space-x-2">
<button
onClick={handleUpload}
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
Upload
</button>
<button
onClick={createFolder}
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
New Folder
</button>
</div>
{uploadProgress > 0 && (
<div className="w-full bg-gray-200 rounded-full h-2.5">
<div
className="bg-blue-600 h-2.5 rounded-full"
style={{ width: `${uploadProgress}%` }}
></div>
</div>
)}
</div>
);
};
export default FileOperations;

Some files were not shown because too many files have changed in this diff Show more