initial commit
21
apps/native/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
node_modules/
|
||||
.expo/
|
||||
dist/
|
||||
npm-debug.*
|
||||
*.jks
|
||||
*.p8
|
||||
*.p12
|
||||
*.key
|
||||
*.mobileprovision
|
||||
*.orig.*
|
||||
web-build/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Temporary files created by Metro to check the health of the file watcher
|
||||
.metro-health-check*
|
||||
|
||||
# UniWind generated types
|
||||
uniwind-types.d.ts
|
||||
|
||||
17
apps/native/app.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"expo": {
|
||||
"scheme": "haushaltsApp",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
},
|
||||
"name": "haushaltsApp",
|
||||
"slug": "haushaltsApp",
|
||||
"plugins": ["expo-font"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
46
apps/native/app/(drawer)/(tabs)/_layout.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Tabs } from "expo-router";
|
||||
import { useThemeColor } from "heroui-native";
|
||||
|
||||
export default function TabLayout() {
|
||||
const themeColorForeground = useThemeColor("foreground");
|
||||
const themeColorBackground = useThemeColor("background");
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
headerStyle: {
|
||||
backgroundColor: themeColorBackground,
|
||||
},
|
||||
headerTintColor: themeColorForeground,
|
||||
headerTitleStyle: {
|
||||
color: themeColorForeground,
|
||||
fontWeight: "600",
|
||||
},
|
||||
tabBarStyle: {
|
||||
backgroundColor: themeColorBackground,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: "Home",
|
||||
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
||||
<Ionicons name="home" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="two"
|
||||
options={{
|
||||
title: "Explore",
|
||||
tabBarIcon: ({ color, size }: { color: string; size: number }) => (
|
||||
<Ionicons name="compass" size={size} color={color} />
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
16
apps/native/app/(drawer)/(tabs)/index.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Card } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<Container className="p-6">
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Card variant="secondary" className="p-8 items-center">
|
||||
<Card.Title className="text-3xl mb-2">Tab One</Card.Title>
|
||||
</Card>
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
16
apps/native/app/(drawer)/(tabs)/two.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Card } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
export default function TabTwo() {
|
||||
return (
|
||||
<Container className="p-6">
|
||||
<View className="flex-1 justify-center items-center">
|
||||
<Card variant="secondary" className="p-8 items-center">
|
||||
<Card.Title className="text-3xl mb-2">TabTwo</Card.Title>
|
||||
</Card>
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
72
apps/native/app/(drawer)/_layout.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import { Ionicons, MaterialIcons } from "@expo/vector-icons";
|
||||
import { Link } from "expo-router";
|
||||
import { Drawer } from "expo-router/drawer";
|
||||
import { useThemeColor } from "heroui-native";
|
||||
import React, { useCallback } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
function DrawerLayout() {
|
||||
const themeColorForeground = useThemeColor("foreground");
|
||||
const themeColorBackground = useThemeColor("background");
|
||||
|
||||
const renderThemeToggle = useCallback(() => <ThemeToggle />, []);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
screenOptions={{
|
||||
headerTintColor: themeColorForeground,
|
||||
headerStyle: { backgroundColor: themeColorBackground },
|
||||
headerTitleStyle: {
|
||||
fontWeight: "600",
|
||||
color: themeColorForeground,
|
||||
},
|
||||
headerRight: renderThemeToggle,
|
||||
drawerStyle: { backgroundColor: themeColorBackground },
|
||||
}}
|
||||
>
|
||||
<Drawer.Screen
|
||||
name="index"
|
||||
options={{
|
||||
headerTitle: "Home",
|
||||
drawerLabel: ({ color, focused }) => (
|
||||
<Text style={{ color: focused ? color : themeColorForeground }}>Home</Text>
|
||||
),
|
||||
drawerIcon: ({ size, color, focused }) => (
|
||||
<Ionicons
|
||||
name="home-outline"
|
||||
size={size}
|
||||
color={focused ? color : themeColorForeground}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
<Drawer.Screen
|
||||
name="(tabs)"
|
||||
options={{
|
||||
headerTitle: "Tabs",
|
||||
drawerLabel: ({ color, focused }) => (
|
||||
<Text style={{ color: focused ? color : themeColorForeground }}>Tabs</Text>
|
||||
),
|
||||
drawerIcon: ({ size, color, focused }) => (
|
||||
<MaterialIcons
|
||||
name="border-bottom"
|
||||
size={size}
|
||||
color={focused ? color : themeColorForeground}
|
||||
/>
|
||||
),
|
||||
headerRight: () => (
|
||||
<Link href="/modal" asChild>
|
||||
<Pressable className="mr-4">
|
||||
<Ionicons name="add-outline" size={24} color={themeColorForeground} />
|
||||
</Pressable>
|
||||
</Link>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
export default DrawerLayout;
|
||||
49
apps/native/app/(drawer)/index.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { Card, Chip, useThemeColor } from "heroui-native";
|
||||
import { Text, View, Pressable } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
import { SignIn } from "@/components/sign-in";
|
||||
import { SignUp } from "@/components/sign-up";
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function Home() {
|
||||
const { data: session } = authClient.useSession();
|
||||
|
||||
const mutedColor = useThemeColor("muted");
|
||||
const successColor = useThemeColor("success");
|
||||
const dangerColor = useThemeColor("danger");
|
||||
const foregroundColor = useThemeColor("foreground");
|
||||
|
||||
return (
|
||||
<Container className="p-6">
|
||||
<View className="py-4 mb-6">
|
||||
<Text className="text-4xl font-bold text-foreground mb-2">BETTER T STACK</Text>
|
||||
</View>
|
||||
|
||||
{session?.user ? (
|
||||
<Card variant="secondary" className="mb-6 p-4">
|
||||
<Text className="text-foreground text-base mb-2">
|
||||
Welcome, <Text className="font-medium">{session.user.name}</Text>
|
||||
</Text>
|
||||
<Text className="text-muted text-sm mb-4">{session.user.email}</Text>
|
||||
<Pressable
|
||||
className="bg-danger py-3 px-4 rounded-lg self-start active:opacity-70"
|
||||
onPress={() => {
|
||||
authClient.signOut();
|
||||
}}
|
||||
>
|
||||
<Text className="text-foreground font-medium">Sign Out</Text>
|
||||
</Pressable>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!session?.user && (
|
||||
<>
|
||||
<SignIn />
|
||||
<SignUp />
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
27
apps/native/app/+not-found.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Link, Stack } from "expo-router";
|
||||
import { Button, Surface } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
export default function NotFoundScreen() {
|
||||
return (
|
||||
<>
|
||||
<Stack.Screen options={{ title: "Not Found" }} />
|
||||
<Container>
|
||||
<View className="flex-1 justify-center items-center p-4">
|
||||
<Surface variant="secondary" className="items-center p-6 max-w-sm rounded-lg">
|
||||
<Text className="text-4xl mb-3">🤔</Text>
|
||||
<Text className="text-foreground font-medium text-lg mb-1">Page Not Found</Text>
|
||||
<Text className="text-muted text-sm text-center mb-4">
|
||||
The page you're looking for doesn't exist.
|
||||
</Text>
|
||||
<Link href="/" asChild>
|
||||
<Button size="sm">Go Home</Button>
|
||||
</Link>
|
||||
</Surface>
|
||||
</View>
|
||||
</Container>
|
||||
</>
|
||||
);
|
||||
}
|
||||
34
apps/native/app/_layout.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import "@/global.css";
|
||||
import { Stack } from "expo-router";
|
||||
import { HeroUINativeProvider } from "heroui-native";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
import { KeyboardProvider } from "react-native-keyboard-controller";
|
||||
|
||||
import { AppThemeProvider } from "@/contexts/app-theme-context";
|
||||
|
||||
export const unstable_settings = {
|
||||
initialRouteName: "(drawer)",
|
||||
};
|
||||
|
||||
function StackLayout() {
|
||||
return (
|
||||
<Stack screenOptions={{}}>
|
||||
<Stack.Screen name="(drawer)" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="modal" options={{ title: "Modal", presentation: "modal" }} />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<KeyboardProvider>
|
||||
<AppThemeProvider>
|
||||
<HeroUINativeProvider>
|
||||
<StackLayout />
|
||||
</HeroUINativeProvider>
|
||||
</AppThemeProvider>
|
||||
</KeyboardProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
37
apps/native/app/modal.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import { router } from "expo-router";
|
||||
import { Button, Surface, useThemeColor } from "heroui-native";
|
||||
import { Text, View } from "react-native";
|
||||
|
||||
import { Container } from "@/components/container";
|
||||
|
||||
function Modal() {
|
||||
const accentForegroundColor = useThemeColor("accent-foreground");
|
||||
|
||||
function handleClose() {
|
||||
router.back();
|
||||
}
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<View className="flex-1 justify-center items-center p-4">
|
||||
<Surface variant="secondary" className="p-5 w-full max-w-sm rounded-lg">
|
||||
<View className="items-center">
|
||||
<View className="w-12 h-12 bg-accent rounded-lg items-center justify-center mb-3">
|
||||
<Ionicons name="checkmark" size={24} color={accentForegroundColor} />
|
||||
</View>
|
||||
<Text className="text-foreground font-medium text-lg mb-1">Modal Screen</Text>
|
||||
<Text className="text-muted text-sm text-center mb-4">
|
||||
This is an example modal screen for dialogs and confirmations.
|
||||
</Text>
|
||||
</View>
|
||||
<Button onPress={handleClose} className="w-full" size="sm">
|
||||
<Button.Label>Close</Button.Label>
|
||||
</Button>
|
||||
</Surface>
|
||||
</View>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default Modal;
|
||||
BIN
apps/native/assets/images/android-icon-background.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/native/assets/images/android-icon-foreground.png
Normal file
|
After Width: | Height: | Size: 77 KiB |
BIN
apps/native/assets/images/android-icon-monochrome.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
apps/native/assets/images/favicon.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
apps/native/assets/images/icon.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
BIN
apps/native/assets/images/partial-react-logo.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
apps/native/assets/images/react-logo.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/native/assets/images/react-logo@2x.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/native/assets/images/react-logo@3x.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/native/assets/images/splash-icon.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
46
apps/native/components/container.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import { cn } from "heroui-native";
|
||||
import { type PropsWithChildren } from "react";
|
||||
import { ScrollView, View, type ScrollViewProps, type ViewProps } from "react-native";
|
||||
import Animated, { type AnimatedProps } from "react-native-reanimated";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
|
||||
const AnimatedView = Animated.createAnimatedComponent(View);
|
||||
|
||||
type Props = AnimatedProps<ViewProps> & {
|
||||
className?: string;
|
||||
isScrollable?: boolean;
|
||||
scrollViewProps?: Omit<ScrollViewProps, "contentContainerStyle">;
|
||||
};
|
||||
|
||||
export function Container({
|
||||
children,
|
||||
className,
|
||||
isScrollable = true,
|
||||
scrollViewProps,
|
||||
...props
|
||||
}: PropsWithChildren<Props>) {
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
return (
|
||||
<AnimatedView
|
||||
className={cn("flex-1 bg-background", className)}
|
||||
style={{
|
||||
paddingBottom: insets.bottom,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{isScrollable ? (
|
||||
<ScrollView
|
||||
contentContainerStyle={{ flexGrow: 1 }}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
contentInsetAdjustmentBehavior="automatic"
|
||||
{...scrollViewProps}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
) : (
|
||||
<View className="flex-1">{children}</View>
|
||||
)}
|
||||
</AnimatedView>
|
||||
);
|
||||
}
|
||||
166
apps/native/components/sign-in.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const signInSchema = z.object({
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown): string | null {
|
||||
if (!error) return null;
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
for (const issue of error) {
|
||||
const message = getErrorMessage(issue);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function SignIn() {
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signInSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign in",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Signed in successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Sign In</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="password"
|
||||
textContentType="password"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Sign In</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
||||
export { SignIn };
|
||||
190
apps/native/components/sign-up.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import {
|
||||
Button,
|
||||
FieldError,
|
||||
Input,
|
||||
Label,
|
||||
Spinner,
|
||||
Surface,
|
||||
TextField,
|
||||
useToast,
|
||||
} from "heroui-native";
|
||||
import { useRef } from "react";
|
||||
import { Text, TextInput, View } from "react-native";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
const signUpSchema = z.object({
|
||||
name: z.string().trim().min(1, "Name is required").min(2, "Name must be at least 2 characters"),
|
||||
email: z.string().trim().min(1, "Email is required").email("Enter a valid email address"),
|
||||
password: z.string().min(1, "Password is required").min(8, "Use at least 8 characters"),
|
||||
});
|
||||
|
||||
function getErrorMessage(error: unknown): string | null {
|
||||
if (!error) return null;
|
||||
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
|
||||
if (Array.isArray(error)) {
|
||||
for (const issue of error) {
|
||||
const message = getErrorMessage(issue);
|
||||
if (message) {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof error === "object" && error !== null) {
|
||||
const maybeError = error as { message?: unknown };
|
||||
if (typeof maybeError.message === "string") {
|
||||
return maybeError.message;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function SignUp() {
|
||||
const emailInputRef = useRef<TextInput>(null);
|
||||
const passwordInputRef = useRef<TextInput>(null);
|
||||
const { toast } = useToast();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
validators: {
|
||||
onSubmit: signUpSchema,
|
||||
},
|
||||
onSubmit: async ({ value, formApi }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
name: value.name.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onError(error) {
|
||||
toast.show({
|
||||
variant: "danger",
|
||||
label: error.error?.message || "Failed to sign up",
|
||||
});
|
||||
},
|
||||
onSuccess() {
|
||||
formApi.reset();
|
||||
toast.show({
|
||||
variant: "success",
|
||||
label: "Account created successfully",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Surface variant="secondary" className="p-4 rounded-lg">
|
||||
<Text className="text-foreground font-medium mb-4">Create Account</Text>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({
|
||||
isSubmitting: state.isSubmitting,
|
||||
validationError: getErrorMessage(state.errorMap.onSubmit),
|
||||
})}
|
||||
>
|
||||
{({ isSubmitting, validationError }) => {
|
||||
const formError = validationError;
|
||||
|
||||
return (
|
||||
<>
|
||||
<FieldError isInvalid={!!formError} className="mb-3">
|
||||
{formError}
|
||||
</FieldError>
|
||||
|
||||
<View className="gap-3">
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="John Doe"
|
||||
autoComplete="name"
|
||||
textContentType="name"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
emailInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
ref={emailInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="email@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
autoComplete="email"
|
||||
textContentType="emailAddress"
|
||||
returnKeyType="next"
|
||||
blurOnSubmit={false}
|
||||
onSubmitEditing={() => {
|
||||
passwordInputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<TextField>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
ref={passwordInputRef}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChangeText={field.handleChange}
|
||||
placeholder="••••••••"
|
||||
secureTextEntry
|
||||
autoComplete="new-password"
|
||||
textContentType="newPassword"
|
||||
returnKeyType="go"
|
||||
onSubmitEditing={form.handleSubmit}
|
||||
/>
|
||||
</TextField>
|
||||
)}
|
||||
</form.Field>
|
||||
|
||||
<Button onPress={form.handleSubmit} isDisabled={isSubmitting} className="mt-1">
|
||||
{isSubmitting ? (
|
||||
<Spinner size="sm" color="default" />
|
||||
) : (
|
||||
<Button.Label>Create Account</Button.Label>
|
||||
)}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
</form.Subscribe>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
35
apps/native/components/theme-toggle.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
import * as Haptics from "expo-haptics";
|
||||
import { Platform, Pressable } from "react-native";
|
||||
import Animated, { FadeOut, ZoomIn } from "react-native-reanimated";
|
||||
import { withUniwind } from "uniwind";
|
||||
|
||||
import { useAppTheme } from "@/contexts/app-theme-context";
|
||||
|
||||
const StyledIonicons = withUniwind(Ionicons);
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { toggleTheme, isLight } = useAppTheme();
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (Platform.OS === "ios") {
|
||||
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
|
||||
}
|
||||
toggleTheme();
|
||||
}}
|
||||
className="px-2.5"
|
||||
>
|
||||
{isLight ? (
|
||||
<Animated.View key="moon" entering={ZoomIn} exiting={FadeOut}>
|
||||
<StyledIonicons name="moon" size={20} className="text-foreground" />
|
||||
</Animated.View>
|
||||
) : (
|
||||
<Animated.View key="sun" entering={ZoomIn} exiting={FadeOut}>
|
||||
<StyledIonicons name="sunny" size={20} className="text-foreground" />
|
||||
</Animated.View>
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
55
apps/native/contexts/app-theme-context.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo } from "react";
|
||||
import { Uniwind, useUniwind } from "uniwind";
|
||||
|
||||
type ThemeName = "light" | "dark";
|
||||
|
||||
type AppThemeContextType = {
|
||||
currentTheme: string;
|
||||
isLight: boolean;
|
||||
isDark: boolean;
|
||||
setTheme: (theme: ThemeName) => void;
|
||||
toggleTheme: () => void;
|
||||
};
|
||||
|
||||
const AppThemeContext = createContext<AppThemeContextType | undefined>(undefined);
|
||||
|
||||
export const AppThemeProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
const { theme } = useUniwind();
|
||||
|
||||
const isLight = useMemo(() => {
|
||||
return theme === "light";
|
||||
}, [theme]);
|
||||
|
||||
const isDark = useMemo(() => {
|
||||
return theme === "dark";
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((newTheme: ThemeName) => {
|
||||
Uniwind.setTheme(newTheme);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = useCallback(() => {
|
||||
Uniwind.setTheme(theme === "light" ? "dark" : "light");
|
||||
}, [theme]);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
currentTheme: theme,
|
||||
isLight,
|
||||
isDark,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
}),
|
||||
[theme, isLight, isDark, setTheme, toggleTheme],
|
||||
);
|
||||
|
||||
return <AppThemeContext.Provider value={value}>{children}</AppThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export function useAppTheme() {
|
||||
const context = useContext(AppThemeContext);
|
||||
if (!context) {
|
||||
throw new Error("useAppTheme must be used within AppThemeProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
5
apps/native/global.css
Normal file
@@ -0,0 +1,5 @@
|
||||
@import "tailwindcss";
|
||||
@import "uniwind";
|
||||
@import "heroui-native/styles";
|
||||
|
||||
@source './node_modules/heroui-native/lib';
|
||||
16
apps/native/lib/auth-client.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { expoClient } from "@better-auth/expo/client";
|
||||
import { env } from "@haushaltsApp/env/native";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
import Constants from "expo-constants";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.EXPO_PUBLIC_SERVER_URL,
|
||||
plugins: [
|
||||
expoClient({
|
||||
scheme: Constants.expoConfig?.scheme as string,
|
||||
storagePrefix: Constants.expoConfig?.scheme as string,
|
||||
storage: SecureStore,
|
||||
}),
|
||||
],
|
||||
});
|
||||
13
apps/native/metro.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
const { getDefaultConfig } = require("expo/metro-config");
|
||||
const { withUniwindConfig } = require("uniwind/metro");
|
||||
const { wrapWithReanimatedMetroConfig } = require("react-native-reanimated/metro-config");
|
||||
|
||||
/** @type {import('expo/metro-config').MetroConfig} */
|
||||
const config = getDefaultConfig(__dirname);
|
||||
|
||||
const uniwindConfig = withUniwindConfig(wrapWithReanimatedMetroConfig(config), {
|
||||
cssEntryFile: "./global.css",
|
||||
dtsFile: "./uniwind-types.d.ts",
|
||||
});
|
||||
|
||||
module.exports = uniwindConfig;
|
||||
59
apps/native/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "native",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "expo-router/entry",
|
||||
"scripts": {
|
||||
"start": "expo start",
|
||||
"dev": "expo start --clear",
|
||||
"android": "expo run:android",
|
||||
"ios": "expo run:ios",
|
||||
"prebuild": "expo prebuild",
|
||||
"web": "expo start --web"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-auth/expo": "catalog:",
|
||||
"@expo/metro-runtime": "~55.0.6",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@gorhom/bottom-sheet": "^5",
|
||||
"@haushaltsApp/env": "workspace:*",
|
||||
"@react-navigation/drawer": "^7.3.9",
|
||||
"@react-navigation/elements": "^2.8.1",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"expo": "^55.0.0",
|
||||
"expo-constants": "~55.0.7",
|
||||
"expo-font": "~55.0.4",
|
||||
"expo-haptics": "~55.0.8",
|
||||
"expo-linking": "~55.0.7",
|
||||
"expo-network": "~55.0.8",
|
||||
"expo-router": "~55.0.2",
|
||||
"expo-secure-store": "~55.0.8",
|
||||
"expo-status-bar": "~55.0.4",
|
||||
"expo-web-browser": "~55.0.9",
|
||||
"heroui-native": "^1.0.0-rc.3",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react-native": "0.83.2",
|
||||
"react-native-gesture-handler": "~2.30.0",
|
||||
"react-native-keyboard-controller": "1.20.7",
|
||||
"react-native-reanimated": "4.2.1",
|
||||
"react-native-safe-area-context": "~5.6.0",
|
||||
"react-native-screens": "~4.23.0",
|
||||
"react-native-svg": "15.15.3",
|
||||
"react-native-web": "^0.21.0",
|
||||
"react-native-worklets": "0.7.2",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-variants": "^3.2.2",
|
||||
"tailwindcss": "catalog:",
|
||||
"uniwind": "^1.4.0",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@haushaltsApp/config": "workspace:*",
|
||||
"@types/node": "^24.10.0",
|
||||
"@types/react": "~19.2.10",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
11
apps/native/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base",
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"]
|
||||
}
|
||||
55
apps/server/.gitignore
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
# prod
|
||||
dist/
|
||||
/build
|
||||
/out/
|
||||
|
||||
# dev
|
||||
.yarn/
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.vscode/*
|
||||
!.vscode/launch.json
|
||||
!.vscode/*.code-snippets
|
||||
.idea/workspace.xml
|
||||
.idea/usage.statistics.xml
|
||||
.idea/shelf
|
||||
.wrangler
|
||||
.alchemy
|
||||
/.next/
|
||||
.vercel
|
||||
prisma/generated/
|
||||
|
||||
|
||||
# deps
|
||||
node_modules/
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
|
||||
# env
|
||||
.env*
|
||||
.env.production
|
||||
!.env.example
|
||||
.dev.vars
|
||||
|
||||
# logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# local db
|
||||
*.db*
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
27
apps/server/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "server",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"scripts": {
|
||||
"build": "tsdown",
|
||||
"check-types": "tsc -b",
|
||||
"compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server",
|
||||
"dev": "bun run --hot src/index.ts",
|
||||
"start": "bun run dist/index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@haushaltsApp/auth": "workspace:*",
|
||||
"@haushaltsApp/db": "workspace:*",
|
||||
"@haushaltsApp/env": "workspace:*",
|
||||
"better-auth": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"hono": "^4.8.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@haushaltsApp/config": "workspace:*",
|
||||
"@types/bun": "catalog:",
|
||||
"tsdown": "^0.16.5",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
26
apps/server/src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { auth } from "@haushaltsApp/auth";
|
||||
import { env } from "@haushaltsApp/env/server";
|
||||
import { Hono } from "hono";
|
||||
import { cors } from "hono/cors";
|
||||
import { logger } from "hono/logger";
|
||||
|
||||
const app = new Hono();
|
||||
|
||||
app.use(logger());
|
||||
app.use(
|
||||
"/*",
|
||||
cors({
|
||||
origin: env.CORS_ORIGIN,
|
||||
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||
allowHeaders: ["Content-Type", "Authorization"],
|
||||
credentials: true,
|
||||
}),
|
||||
);
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw));
|
||||
|
||||
app.get("/", (c) => {
|
||||
return c.text("OK");
|
||||
});
|
||||
|
||||
export default app;
|
||||
13
apps/server/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"extends": "@haushaltsApp/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "dist",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx"
|
||||
}
|
||||
}
|
||||
9
apps/server/tsdown.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
export default defineConfig({
|
||||
entry: "./src/index.ts",
|
||||
format: "esm",
|
||||
outDir: "./dist",
|
||||
clean: true,
|
||||
noExternal: [/@haushaltsApp\/.*/],
|
||||
});
|
||||
60
apps/web/.gitignore
vendored
Normal file
@@ -0,0 +1,60 @@
|
||||
# Dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# Testing
|
||||
/coverage
|
||||
|
||||
# Build outputs
|
||||
/.next/
|
||||
/out/
|
||||
/build/
|
||||
/dist/
|
||||
.vinxi
|
||||
.output
|
||||
.react-router/
|
||||
.tanstack/
|
||||
.nitro/
|
||||
|
||||
# Deployment
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
.alchemy
|
||||
|
||||
# Environment & local files
|
||||
.env*
|
||||
!.env.example
|
||||
.DS_Store
|
||||
*.pem
|
||||
*.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
*.log*
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# IDE
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
|
||||
# Other
|
||||
dev-dist
|
||||
|
||||
.wrangler
|
||||
.dev.vars*
|
||||
|
||||
.open-next
|
||||
24
apps/web/components.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-lyra",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "../../packages/ui/src/styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@haushaltsApp/ui/lib/utils",
|
||||
"ui": "@haushaltsApp/ui/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
13
apps/web/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>haushaltsApp</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
43
apps/web/package.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"start": "vite",
|
||||
"check-types": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@haushaltsApp/auth": "workspace:*",
|
||||
"@haushaltsApp/env": "workspace:*",
|
||||
"@haushaltsApp/ui": "workspace:*",
|
||||
"@hookform/resolvers": "^5.1.1",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/react-form": "catalog:",
|
||||
"@tanstack/react-router": "^1.141.1",
|
||||
"better-auth": "catalog:",
|
||||
"dotenv": "catalog:",
|
||||
"lucide-react": "catalog:",
|
||||
"next-themes": "catalog:",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"sonner": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@haushaltsApp/config": "workspace:*",
|
||||
"@tanstack/react-router-devtools": "^1.141.1",
|
||||
"@tanstack/router-plugin": "^1.141.1",
|
||||
"@types/node": "^22.13.14",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "catalog:",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "catalog:",
|
||||
"typescript": "^5",
|
||||
"vite": "^6.2.2"
|
||||
}
|
||||
}
|
||||
32
apps/web/src/components/header.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Link } from "@tanstack/react-router";
|
||||
|
||||
import { ModeToggle } from "./mode-toggle";
|
||||
import UserMenu from "./user-menu";
|
||||
|
||||
export default function Header() {
|
||||
const links = [
|
||||
{ to: "/", label: "Home" },
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
] as const;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-row items-center justify-between px-2 py-1">
|
||||
<nav className="flex gap-4 text-lg">
|
||||
{links.map(({ to, label }) => {
|
||||
return (
|
||||
<Link key={to} to={to}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="flex items-center gap-2">
|
||||
<ModeToggle />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
apps/web/src/components/loader.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function Loader() {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center pt-8">
|
||||
<Loader2 className="animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
29
apps/web/src/components/mode-toggle.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Button } from "@haushaltsApp/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@haushaltsApp/ui/components/dropdown-menu";
|
||||
import { Moon, Sun } from "lucide-react";
|
||||
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
|
||||
export function ModeToggle() {
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" size="icon" />}>
|
||||
<Sun className="h-[1.2rem] w-[1.2rem] scale-100 rotate-0 transition-all dark:scale-0 dark:-rotate-90" />
|
||||
<Moon className="absolute h-[1.2rem] w-[1.2rem] scale-0 rotate-90 transition-all dark:scale-100 dark:rotate-0" />
|
||||
<span className="sr-only">Toggle theme</span>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setTheme("light")}>Light</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("dark")}>Dark</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => setTheme("system")}>System</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
135
apps/web/src/components/sign-in-form.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
import { Button } from "@haushaltsApp/ui/components/button";
|
||||
import { Input } from "@haushaltsApp/ui/components/input";
|
||||
import { Label } from "@haushaltsApp/ui/components/label";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
import Loader from "./loader";
|
||||
|
||||
export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signIn.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign in successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Welcome Back</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Sign In"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignUp}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Need an account? Sign Up
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
apps/web/src/components/sign-up-form.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Button } from "@haushaltsApp/ui/components/button";
|
||||
import { Input } from "@haushaltsApp/ui/components/input";
|
||||
import { Label } from "@haushaltsApp/ui/components/label";
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
import Loader from "./loader";
|
||||
|
||||
export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) {
|
||||
const navigate = useNavigate({
|
||||
from: "/",
|
||||
});
|
||||
const { isPending } = authClient.useSession();
|
||||
|
||||
const form = useForm({
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
name: "",
|
||||
},
|
||||
onSubmit: async ({ value }) => {
|
||||
await authClient.signUp.email(
|
||||
{
|
||||
email: value.email,
|
||||
password: value.password,
|
||||
name: value.name,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/dashboard",
|
||||
});
|
||||
toast.success("Sign up successful");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.error.message || error.error.statusText);
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
validators: {
|
||||
onSubmit: z.object({
|
||||
name: z.string().min(2, "Name must be at least 2 characters"),
|
||||
email: z.email("Invalid email address"),
|
||||
password: z.string().min(8, "Password must be at least 8 characters"),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <Loader />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full mt-10 max-w-md p-6">
|
||||
<h1 className="mb-6 text-center text-3xl font-bold">Create Account</h1>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
form.handleSubmit();
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<form.Field name="name">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Name</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="email">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Email</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="email"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<form.Field name="password">
|
||||
{(field) => (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={field.name}>Password</Label>
|
||||
<Input
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
type="password"
|
||||
value={field.state.value}
|
||||
onBlur={field.handleBlur}
|
||||
onChange={(e) => field.handleChange(e.target.value)}
|
||||
/>
|
||||
{field.state.meta.errors.map((error) => (
|
||||
<p key={error?.message} className="text-red-500">
|
||||
{error?.message}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</form.Field>
|
||||
</div>
|
||||
|
||||
<form.Subscribe
|
||||
selector={(state) => ({ canSubmit: state.canSubmit, isSubmitting: state.isSubmitting })}
|
||||
>
|
||||
{({ canSubmit, isSubmitting }) => (
|
||||
<Button type="submit" className="w-full" disabled={!canSubmit || isSubmitting}>
|
||||
{isSubmitting ? "Submitting..." : "Sign Up"}
|
||||
</Button>
|
||||
)}
|
||||
</form.Subscribe>
|
||||
</form>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={onSwitchToSignIn}
|
||||
className="text-indigo-600 hover:text-indigo-800"
|
||||
>
|
||||
Already have an account? Sign In
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
apps/web/src/components/theme-provider.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import * as React from "react";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
|
||||
export { useTheme } from "next-themes";
|
||||
62
apps/web/src/components/user-menu.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Button } from "@haushaltsApp/ui/components/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@haushaltsApp/ui/components/dropdown-menu";
|
||||
import { Skeleton } from "@haushaltsApp/ui/components/skeleton";
|
||||
import { Link, useNavigate } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export default function UserMenu() {
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = authClient.useSession();
|
||||
|
||||
if (isPending) {
|
||||
return <Skeleton className="h-9 w-24" />;
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Link to="/login">
|
||||
<Button variant="outline">Sign In</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger render={<Button variant="outline" />}>
|
||||
{session.user.name}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="bg-card">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>{session.user.email}</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
authClient.signOut({
|
||||
fetchOptions: {
|
||||
onSuccess: () => {
|
||||
navigate({
|
||||
to: "/",
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign Out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/index.css
Normal file
@@ -0,0 +1 @@
|
||||
@import "@haushaltsApp/ui/globals.css";
|
||||
6
apps/web/src/lib/auth-client.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { env } from "@haushaltsApp/env/web";
|
||||
import { createAuthClient } from "better-auth/react";
|
||||
|
||||
export const authClient = createAuthClient({
|
||||
baseURL: env.VITE_SERVER_URL,
|
||||
});
|
||||
29
apps/web/src/main.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
import ReactDOM from "react-dom/client";
|
||||
|
||||
import Loader from "./components/loader";
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: "intent",
|
||||
defaultPendingComponent: () => <Loader />,
|
||||
context: {},
|
||||
});
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById("app");
|
||||
|
||||
if (!rootElement) {
|
||||
throw new Error("Root element not found");
|
||||
}
|
||||
|
||||
if (!rootElement.innerHTML) {
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
root.render(<RouterProvider router={router} />);
|
||||
}
|
||||
52
apps/web/src/routes/__root.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Toaster } from "@haushaltsApp/ui/components/sonner";
|
||||
import { HeadContent, Outlet, createRootRouteWithContext } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
|
||||
import Header from "@/components/header";
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
|
||||
import "../index.css";
|
||||
|
||||
export interface RouterAppContext {}
|
||||
|
||||
export const Route = createRootRouteWithContext<RouterAppContext>()({
|
||||
component: RootComponent,
|
||||
head: () => ({
|
||||
meta: [
|
||||
{
|
||||
title: "haushaltsApp",
|
||||
},
|
||||
{
|
||||
name: "description",
|
||||
content: "haushaltsApp is a web application",
|
||||
},
|
||||
],
|
||||
links: [
|
||||
{
|
||||
rel: "icon",
|
||||
href: "/favicon.ico",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
function RootComponent() {
|
||||
return (
|
||||
<>
|
||||
<HeadContent />
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="dark"
|
||||
disableTransitionOnChange
|
||||
storageKey="vite-ui-theme"
|
||||
>
|
||||
<div className="grid grid-rows-[auto_1fr] h-svh">
|
||||
<Header />
|
||||
<Outlet />
|
||||
</div>
|
||||
<Toaster richColors />
|
||||
</ThemeProvider>
|
||||
<TanStackRouterDevtools position="bottom-left" />
|
||||
</>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/routes/dashboard.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||
|
||||
import { authClient } from "@/lib/auth-client";
|
||||
|
||||
export const Route = createFileRoute("/dashboard")({
|
||||
component: RouteComponent,
|
||||
beforeLoad: async () => {
|
||||
const session = await authClient.getSession();
|
||||
if (!session.data) {
|
||||
redirect({
|
||||
to: "/login",
|
||||
throw: true,
|
||||
});
|
||||
}
|
||||
return { session };
|
||||
},
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const { session } = Route.useRouteContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
<p>Welcome {session.data?.user.name}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/routes/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: HomeComponent,
|
||||
});
|
||||
|
||||
const TITLE_TEXT = `
|
||||
██████╗ ███████╗████████╗████████╗███████╗██████╗
|
||||
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
|
||||
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝
|
||||
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗
|
||||
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║
|
||||
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
|
||||
|
||||
████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗
|
||||
╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
|
||||
██║ ███████╗ ██║ ███████║██║ █████╔╝
|
||||
██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗
|
||||
██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗
|
||||
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
|
||||
`;
|
||||
|
||||
function HomeComponent() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-3xl px-4 py-2">
|
||||
<pre className="overflow-x-auto font-mono text-sm">{TITLE_TEXT}</pre>
|
||||
<div className="grid gap-6">
|
||||
<section className="rounded-lg border p-4">
|
||||
<h2 className="mb-2 font-medium">API Status</h2>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/web/src/routes/login.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { useState } from "react";
|
||||
|
||||
import SignInForm from "@/components/sign-in-form";
|
||||
import SignUpForm from "@/components/sign-up-form";
|
||||
|
||||
export const Route = createFileRoute("/login")({
|
||||
component: RouteComponent,
|
||||
});
|
||||
|
||||
function RouteComponent() {
|
||||
const [showSignIn, setShowSignIn] = useState(false);
|
||||
|
||||
return showSignIn ? (
|
||||
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
|
||||
) : (
|
||||
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
|
||||
);
|
||||
}
|
||||
19
apps/web/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "react-jsx",
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"types": ["vite/client"],
|
||||
"rootDirs": ["."],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@haushaltsApp/ui/*": ["../../packages/ui/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
18
apps/web/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import path from "node:path";
|
||||
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), tanstackRouter({}), react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
},
|
||||
});
|
||||