initial commit

This commit is contained in:
-Puter
2026-07-19 02:46:47 +05:30
commit 8033a8edb0
121 changed files with 7845 additions and 0 deletions

23
.codex/config.toml Normal file
View File

@@ -0,0 +1,23 @@
[mcp_servers.better-t-stack]
command = "bunx"
args = ["create-better-t-stack@latest", "mcp"]
[mcp_servers.context7]
command = "npx"
args = ["-y", "@upstash/context7-mcp"]
[mcp_servers.convex]
command = "npx"
args = ["-y", "convex@latest", "mcp", "start"]
[mcp_servers.shadcn]
command = "npx"
args = ["-y", "shadcn@latest", "mcp"]
[mcp_servers.better-auth]
type = "http"
url = "https://mcp.inkeep.com/better-auth/mcp"
[mcp_servers.expo-mcp]
type = "http"
url = "https://mcp.expo.dev/mcp"

55
.gitignore vendored Normal file
View File

@@ -0,0 +1,55 @@
# Dependencies
node_modules
.pnp
.pnp.js
# Build outputs
dist
build
*.tsbuildinfo
# Generated files
apps/web/src/routeTree.gen.ts
# Environment variables
.env
.env*.local
# IDEs and editors
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea
*.swp
*.swo
*~
.DS_Store
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Turbo
.turbo
.nx
# Better-T-Stack
.alchemy
.vercel
# Testing
coverage
.nyc_output
# Misc
*.tgz
.cache
tmp
temp

108
README.md Normal file
View File

@@ -0,0 +1,108 @@
# code
This project was created with [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack), a modern TypeScript stack that combines React, React Router, Convex, and more.
## Features
- **TypeScript** - For type safety and improved developer experience
- **React Router** - Declarative routing for React
- **React Native** - Build mobile apps using React
- **Expo** - Tools for React Native development
- **TailwindCSS** - Utility-first CSS for rapid UI development
- **Shared UI package** - shadcn/ui primitives live in `packages/ui`
- **Convex** - Reactive backend-as-a-service platform
- **Authentication** - Better-Auth
- **Electrobun** - Lightweight desktop shell for web frontends
- **Oxlint** - Oxlint + Oxfmt (linting & formatting)
- **Vite+** - Unified Vite toolchain, workspace task runner, linting, and formatting
## Getting Started
First, install the dependencies:
```bash
bun install
```
## Convex Setup
This project uses Convex as a backend. You'll need to set up Convex before running the app:
```bash
bun run dev:setup
```
Follow the prompts to create a new Convex project and connect it to your application.
Copy environment variables from `packages/backend/.env.local` to `apps/*/.env`.
Then, run the development server:
```bash
bun run dev
```
Open [http://localhost:5173](http://localhost:5173) in your browser to see the web application.
Use the Expo Go app to run the mobile application.
Your app will connect to the Convex cloud backend automatically.
## UI Customization
React web apps in this stack share shadcn/ui primitives through `packages/ui`.
- Change design tokens and global styles in `packages/ui/src/styles/globals.css`
- Update shared primitives in `packages/ui/src/components/*`
- Adjust shadcn aliases or style config in `packages/ui/components.json` and `apps/web/components.json`
### Add more shared components
Run this from the project root to add more primitives to the shared UI package:
```bash
npx shadcn@latest add accordion dialog popover sheet table -c packages/ui
```
Import shared components like this:
```tsx
import { Button } from "@code/ui/components/button";
```
### Add app-specific blocks
If you want to add app-specific blocks instead of shared primitives, run the shadcn CLI from `apps/web`.
## Git Hooks and Formatting
- Optional native Vite+ hooks: `bun run hooks:setup`
- Docs: [Vite+ commit hooks](https://viteplus.dev/guide/commit-hooks)
- Run checks: `bun run check`
## Project Structure
```
code/
├── apps/
│ ├── web/ # Frontend application (React + React Router)
│ ├── native/ # Mobile application (React Native, Expo)
├── packages/
│ ├── ui/ # Shared shadcn/ui components and styles
│ ├── backend/ # Convex backend functions and schema
```
## Available Scripts
- `bun run dev`: Start all applications in development mode
- `bun run build`: Build all applications
- `bun run dev:web`: Start only the web application
- `bun run dev:setup`: Setup and configure your Convex project
- `bun run check-types`: Check TypeScript types across all apps
- `bun run dev:native`: Start the React Native/Expo development server
- `bun run check`: Run Vite+ format/lint checks and workspace TypeScript checks
- `bun run lint`: Run Vite+ lint checks
- `bun run format`: Run Vite+ formatting
- `bun run staged`: Run Vite+ checks against staged files
- `bun run hooks:setup`: Install Vite+ native Git hooks with `vp config`
- `bun run dev:desktop`: Start the Electrobun desktop app with HMR
- `bun run build:desktop`: Build the stable Electrobun desktop app
- `bun run build:desktop:canary`: Build the canary Electrobun desktop app

1
apps/desktop/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
artifacts

View File

@@ -0,0 +1,35 @@
import type { ElectrobunConfig } from "electrobun";
const webBuildDir = "../web/build/client";
export default {
app: {
name: "code",
identifier: "dev.bettertstack.code.desktop",
version: "0.0.1",
},
runtime: {
exitOnLastWindowClosed: true,
},
build: {
bun: {
entrypoint: "src/bun/index.ts",
},
copy: {
[webBuildDir]: "views/mainview",
},
watchIgnore: [`${webBuildDir}/**`],
mac: {
bundleCEF: true,
defaultRenderer: "cef",
},
linux: {
bundleCEF: true,
defaultRenderer: "cef",
},
win: {
bundleCEF: true,
defaultRenderer: "cef",
},
},
} satisfies ElectrobunConfig;

23
apps/desktop/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "desktop",
"private": true,
"type": "module",
"scripts": {
"start": "electrobun dev",
"dev:hmr": "concurrently \"bun run hmr\" \"electrobun dev --watch\"",
"hmr": "vp run --filter web dev",
"build": "vp run --filter web build && electrobun build",
"build:stable": "vp run --filter web build && electrobun build --env=stable",
"build:canary": "vp run --filter web build && electrobun build --env=canary",
"check-types": "tsc --noEmit"
},
"dependencies": {
"electrobun": "^1.18.1"
},
"devDependencies": {
"@types/bun": "^1.3.14",
"@types/three": "^0.165.0",
"concurrently": "^10.0.3",
"typescript": "^6"
}
}

View File

@@ -0,0 +1,34 @@
import { BrowserWindow, Updater } from "electrobun/bun";
const DEV_SERVER_PORT = 5173;
const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
async function getMainViewUrl(): Promise<string> {
const channel = await Updater.localInfo.channel();
if (channel === "dev") {
try {
await fetch(DEV_SERVER_URL, { method: "HEAD" });
console.log(`HMR enabled: Using web dev server at ${DEV_SERVER_URL}`);
return DEV_SERVER_URL;
} catch {
console.log("Web dev server not running. Run dev:hmr for live reload.");
}
}
return "views://mainview/index.html";
}
const url = await getMainViewUrl();
new BrowserWindow({
title: "code",
url,
frame: {
width: 1280,
height: 820,
x: 120,
y: 120,
},
});
console.log("Electrobun desktop shell started.");

View File

@@ -0,0 +1,15 @@
{
"extends": "../../packages/config/tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM"],
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"noEmit": true,
"paths": {
"@/*": ["./src/*"]
},
"strictNullChecks": true
},
"include": ["src/**/*.ts", "electrobun.config.ts"]
}

21
apps/native/.gitignore vendored Normal file
View 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
View File

@@ -0,0 +1,17 @@
{
"expo": {
"scheme": "code",
"userInterfaceStyle": "automatic",
"orientation": "default",
"web": {
"bundler": "metro"
},
"name": "code",
"slug": "code",
"plugins": ["expo-font"],
"experiments": {
"typedRoutes": true,
"reactCompiler": true
}
}
}

View File

@@ -0,0 +1,42 @@
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 }) => <Ionicons name="home" size={size} color={color} />,
}}
/>
<Tabs.Screen
name="two"
options={{
title: "Explore",
tabBarIcon: ({ color, size }) => <Ionicons name="compass" size={size} color={color} />,
}}
/>
</Tabs>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,88 @@
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.Screen
name="todos"
options={{
headerTitle: "Todos",
drawerLabel: ({ color, focused }) => (
<Text style={{ color: focused ? color : themeColorForeground }}>Todos</Text>
),
drawerIcon: ({ size, color, focused }) => (
<Ionicons
name="checkbox-outline"
size={size}
color={focused ? color : themeColorForeground}
/>
),
}}
/>
</Drawer>
);
}
export default DrawerLayout;

View File

@@ -0,0 +1,72 @@
import { api } from "@code/backend/convex/_generated/api";
import { useConvexAuth, useQuery } from "convex/react";
import { Button, Chip, Separator, Spinner, Surface, useThemeColor } from "heroui-native";
import { Text, View } 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 healthCheck = useQuery(api.healthCheck.get);
const { isAuthenticated } = useConvexAuth();
const user = useQuery(api.auth.getCurrentUser, isAuthenticated ? {} : "skip");
const successColor = useThemeColor("success");
const dangerColor = useThemeColor("danger");
const isConnected = healthCheck === "OK";
const isLoading = healthCheck === undefined;
return (
<Container className="px-4 pb-4">
<View className="py-6 mb-5">
<Text className="text-3xl font-semibold text-foreground tracking-tight">
Better T Stack
</Text>
<Text className="text-muted text-sm mt-1">Full-stack TypeScript starter</Text>
</View>
{user ? (
<Surface variant="secondary" className="mb-4 p-4 rounded-xl">
<View className="flex-row items-center justify-between">
<View className="flex-1">
<Text className="text-foreground font-medium">{user.name}</Text>
<Text className="text-muted text-xs mt-0.5">{user.email}</Text>
</View>
<Button
variant="danger"
size="sm"
onPress={() => {
authClient.signOut();
}}
>
Sign Out
</Button>
</View>
</Surface>
) : null}
<Surface variant="secondary" className="p-4 rounded-xl">
<Text className="text-foreground font-medium mb-2">API Status</Text>
<View className="flex-row items-center gap-2">
<View
className={`w-2 h-2 rounded-full ${healthCheck === "OK" ? "bg-success" : "bg-danger"}`}
/>
<Text className="text-muted text-xs">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected to API"
: "API Disconnected"}
</Text>
</View>
</Surface>
{!user && (
<View className="mt-5 gap-4">
<SignIn />
<SignUp />
</View>
)}
</Container>
);
}

View File

@@ -0,0 +1,149 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Doc, Id } from "@code/backend/convex/_generated/dataModel";
import { Ionicons } from "@expo/vector-icons";
import { useMutation, useQuery } from "convex/react";
import {
Button,
Checkbox,
Chip,
Spinner,
Surface,
Input,
TextField,
useThemeColor,
} from "heroui-native";
import { useState } from "react";
import { View, Text, ScrollView, Alert } from "react-native";
import { Container } from "@/components/container";
export default function TodosScreen() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodoMutation = useMutation(api.todos.create);
const toggleTodoMutation = useMutation(api.todos.toggle);
const deleteTodoMutation = useMutation(api.todos.deleteTodo);
const mutedColor = useThemeColor("muted");
const dangerColor = useThemeColor("danger");
const foregroundColor = useThemeColor("foreground");
const handleAddTodo = async () => {
const text = newTodoText.trim();
if (!text) return;
await createTodoMutation({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodoMutation({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
Alert.alert("Delete Todo", "Are you sure you want to delete this todo?", [
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: () => deleteTodoMutation({ id }),
},
]);
};
const isLoading = !todos;
const completedCount = todos?.filter((t: Doc<"todos">) => t.completed).length || 0;
const totalCount = todos?.length || 0;
return (
<Container>
<ScrollView className="flex-1" contentContainerClassName="p-4">
<View className="py-4 mb-4">
<View className="flex-row items-center justify-between">
<Text className="text-2xl font-semibold text-foreground tracking-tight">Tasks</Text>
{totalCount > 0 && (
<Chip variant="secondary" color="accent" size="sm">
<Chip.Label>
{completedCount}/{totalCount}
</Chip.Label>
</Chip>
)}
</View>
</View>
<Surface variant="secondary" className="mb-4 p-3 rounded-lg">
<View className="flex-row items-center gap-2">
<View className="flex-1">
<TextField>
<Input
value={newTodoText}
onChangeText={setNewTodoText}
placeholder="Add a new task..."
onSubmitEditing={handleAddTodo}
returnKeyType="done"
/>
</TextField>
</View>
<Button
isIconOnly
variant={!newTodoText.trim() ? "secondary" : "primary"}
isDisabled={!newTodoText.trim()}
onPress={handleAddTodo}
size="sm"
>
<Ionicons
name="add"
size={20}
color={newTodoText.trim() ? foregroundColor : mutedColor}
/>
</Button>
</View>
</Surface>
{isLoading && (
<View className="items-center justify-center py-12">
<Spinner size="lg" />
<Text className="text-muted text-sm mt-3">Loading tasks...</Text>
</View>
)}
{todos && todos.length === 0 && !isLoading && (
<Surface variant="secondary" className="items-center justify-center py-10 rounded-lg">
<Ionicons name="checkbox-outline" size={40} color={mutedColor} />
<Text className="text-foreground font-medium mt-3">No tasks yet</Text>
<Text className="text-muted text-xs mt-1">Add your first task to get started</Text>
</Surface>
)}
{todos && todos.length > 0 && (
<View className="gap-2">
{todos.map((todo: Doc<"todos">) => (
<Surface key={todo._id} variant="secondary" className="p-3 rounded-lg">
<View className="flex-row items-center gap-3">
<Checkbox
isSelected={todo.completed}
onSelectedChange={() => handleToggleTodo(todo._id, todo.completed)}
/>
<View className="flex-1">
<Text
className={`text-sm ${todo.completed ? "text-muted line-through" : "text-foreground"}`}
>
{todo.text}
</Text>
</View>
<Button
isIconOnly
variant="ghost"
onPress={() => handleDeleteTodo(todo._id)}
size="sm"
>
<Ionicons name="trash-outline" size={16} color={dangerColor} />
</Button>
</View>
</Surface>
))}
</View>
)}
</ScrollView>
</Container>
);
}

View 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>
</>
);
}

View File

@@ -0,0 +1,44 @@
import "@/global.css";
import { env } from "@code/env/native";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
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";
import { authClient } from "@/lib/auth-client";
export const unstable_settings = {
initialRouteName: "(drawer)",
};
const convex = new ConvexReactClient(env.EXPO_PUBLIC_CONVEX_URL, {
unsavedChangesWarning: false,
});
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 (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
<GestureHandlerRootView style={{ flex: 1 }}>
<KeyboardProvider>
<AppThemeProvider>
<HeroUINativeProvider>
<StackLayout />
</HeroUINativeProvider>
</AppThemeProvider>
</KeyboardProvider>
</GestureHandlerRootView>
</ConvexBetterAuthProvider>
);
}

37
apps/native/app/modal.tsx Normal file
View 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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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>
);
}

View File

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

View 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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1,5 @@
@import "tailwindcss";
@import "uniwind";
@import "heroui-native/styles";
@source './node_modules/heroui-native/lib';

View File

@@ -0,0 +1,21 @@
import { expoClient } from "@better-auth/expo/client";
import { env } from "@code/env/native";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
import Constants from "expo-constants";
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
export const authClient = createAuthClient({
baseURL: env.EXPO_PUBLIC_CONVEX_SITE_URL,
plugins: [
convexClient(),
Platform.OS === "web"
? crossDomainClient()
: expoClient({
scheme: Constants.expoConfig?.scheme as string,
storagePrefix: Constants.expoConfig?.scheme as string,
storage: SecureStore,
}),
],
});

View 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;

60
apps/native/package.json Normal file
View File

@@ -0,0 +1,60 @@
{
"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:",
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@expo/metro-runtime": "~57.0.2",
"@expo/vector-icons": "^15.1.1",
"@gorhom/bottom-sheet": "^5.2.14",
"@tanstack/react-form": "catalog:",
"better-auth": "catalog:",
"convex": "catalog:",
"dotenv": "catalog:",
"expo": "~57.0.1",
"expo-constants": "~57.0.2",
"expo-font": "~57.0.0",
"expo-haptics": "~57.0.0",
"expo-linking": "~57.0.1",
"expo-network": "~57.0.0",
"expo-router": "~57.0.2",
"expo-secure-store": "~57.0.0",
"expo-status-bar": "~57.0.0",
"expo-web-browser": "~57.0.0",
"heroui-native": "^1.0.5",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-native": "0.86.0",
"react-native-gesture-handler": "~2.32.0",
"react-native-keyboard-controller": "1.21.9",
"react-native-reanimated": "4.5.0",
"react-native-safe-area-context": "~5.7.0",
"react-native-screens": "4.25.2",
"react-native-svg": "15.15.4",
"react-native-web": "~0.21.0",
"react-native-worklets": "0.10.0",
"tailwind-merge": "catalog:",
"tailwind-variants": "^3.2.2",
"tailwindcss": "catalog:",
"uniwind": "^1.10.0",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/node": "^26.0.1",
"@types/react": "~19.2.17",
"typescript": "~6.0.3"
}
}

10
apps/native/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": ["./*"]
}
},
"include": ["**/*.ts", "**/*.tsx", ".expo/types/**/*.ts", "expo-env.d.ts"]
}

3
apps/native/uniwind-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="uniwind/types" />
declare module "*.css";

34
apps/tui/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
*.log
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

15
apps/tui/README.md Normal file
View File

@@ -0,0 +1,15 @@
# react
To install dependencies:
```bash
bun install
```
To run:
```bash
bun dev
```
This project was created using `bun create tui`. [create-tui](https://git.new/create-tui) is the easiest way to get started with OpenTUI.

21
apps/tui/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "tui",
"private": true,
"type": "module",
"module": "src/index.tsx",
"scripts": {
"dev": "bun run --watch src/index.tsx",
"check-types": "tsc --noEmit"
},
"dependencies": {
"@opentui/core": "^0.4.4",
"@opentui/react": "^0.4.4",
"react": "^19.2.6"
},
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

16
apps/tui/src/index.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { createCliRenderer, TextAttributes } from "@opentui/core";
import { createRoot } from "@opentui/react";
function App() {
return (
<box alignItems="center" justifyContent="center" flexGrow={1}>
<box justifyContent="center" alignItems="flex-end">
<ascii-font font="tiny" text="OpenTUI" />
<text attributes={TextAttributes.DIM}>What will you build?</text>
</box>
</box>
);
}
const renderer = await createCliRenderer();
createRoot(renderer).render(<App />);

30
apps/tui/tsconfig.json Normal file
View File

@@ -0,0 +1,30 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"jsxImportSource": "@opentui/react",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

60
apps/web/.gitignore vendored Normal file
View 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
View 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": "@code/ui/lib/utils",
"ui": "@code/ui/components",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

45
apps/web/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "web",
"private": true,
"type": "module",
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
"dependencies": {
"@code/backend": "workspace:*",
"@code/env": "workspace:*",
"@code/ui": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@react-router/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0",
"@tanstack/react-form": "catalog:",
"better-auth": "catalog:",
"convex": "catalog:",
"dotenv": "catalog:",
"isbot": "^5.1.44",
"lucide-react": "catalog:",
"next-themes": "catalog:",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router": "^8.1.0",
"sonner": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@react-router/dev": "^8.1.0",
"@tailwindcss/vite": "^4.3.2",
"@types/node": "^22.13.14",
"@types/react": "^19.2.17",
"@types/react-dom": "catalog:",
"react-router-devtools": "^6.2.1",
"tailwindcss": "catalog:",
"typescript": "^6",
"vite": "^7.3.6",
"vite-tsconfig-paths": "^6.1.1"
}
}

BIN
apps/web/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,7 @@
import type { Config } from "@react-router/dev/config";
export default {
// Desktop addons package static web assets; SSR output cannot be bundled
ssr: false,
appDirectory: "src",
} satisfies Config;

View File

@@ -0,0 +1,36 @@
import { NavLink } from "react-router";
import { ModeToggle } from "./mode-toggle";
export default function Header() {
const links = [
{ to: "/", label: "Home" },
{ to: "/dashboard", label: "Dashboard" },
{ to: "/todos", label: "Todos" },
] 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 (
<NavLink
key={to}
to={to}
className={({ isActive }) => (isActive ? "font-bold" : "")}
end
>
{label}
</NavLink>
);
})}
</nav>
<div className="flex items-center gap-2">
<ModeToggle />
</div>
</div>
<hr />
</div>
);
}

View 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>
);
}

View File

@@ -0,0 +1,29 @@
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@code/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>
);
}

View File

@@ -0,0 +1,127 @@
import { Button } from "@code/ui/components/button";
import { Input } from "@code/ui/components/input";
import { Label } from "@code/ui/components/label";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
export default function SignInForm({ onSwitchToSignUp }: { onSwitchToSignUp: () => void }) {
const navigate = useNavigate();
const form = useForm({
defaultValues: {
email: "",
password: "",
},
onSubmit: async ({ value }) => {
await authClient.signIn.email(
{
email: value.email,
password: value.password,
},
{
onSuccess: () => {
navigate("/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"),
}),
},
});
return (
<div className="mx-auto mt-10 w-full 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, index) => (
<p key={`${field.name}-error-${index}`} 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, index) => (
<p key={`${field.name}-error-${index}`} 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>
);
}

View File

@@ -0,0 +1,152 @@
import { Button } from "@code/ui/components/button";
import { Input } from "@code/ui/components/input";
import { Label } from "@code/ui/components/label";
import { useForm } from "@tanstack/react-form";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import z from "zod";
import { authClient } from "@/lib/auth-client";
export default function SignUpForm({ onSwitchToSignIn }: { onSwitchToSignIn: () => void }) {
const navigate = useNavigate();
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("/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"),
}),
},
});
return (
<div className="mx-auto mt-10 w-full 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, index) => (
<p key={`${field.name}-error-${index}`} 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, index) => (
<p key={`${field.name}-error-${index}`} 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, index) => (
<p key={`${field.name}-error-${index}`} 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>
);
}

View 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";

View File

@@ -0,0 +1,47 @@
import { api } from "@code/backend/convex/_generated/api";
import { Button } from "@code/ui/components/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@code/ui/components/dropdown-menu";
import { useQuery } from "convex/react";
import { useNavigate } from "react-router";
import { authClient } from "@/lib/auth-client";
export default function UserMenu() {
const navigate = useNavigate();
const user = useQuery(api.auth.getCurrentUser);
return (
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="outline" />}>{user?.name}</DropdownMenuTrigger>
<DropdownMenuContent className="bg-card">
<DropdownMenuGroup>
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem>{user?.email}</DropdownMenuItem>
<DropdownMenuItem
variant="destructive"
onClick={() => {
authClient.signOut({
fetchOptions: {
onSuccess: () => {
navigate("/dashboard");
},
},
});
}}
>
Sign Out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
);
}

1
apps/web/src/index.css Normal file
View File

@@ -0,0 +1 @@
@import "@code/ui/globals.css";

View File

@@ -0,0 +1,8 @@
import { env } from "@code/env/web";
import { convexClient, crossDomainClient } from "@convex-dev/better-auth/client/plugins";
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient({
baseURL: env.VITE_CONVEX_SITE_URL,
plugins: [convexClient(), crossDomainClient()],
});

92
apps/web/src/root.tsx Normal file
View File

@@ -0,0 +1,92 @@
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner";
import "./index.css";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { ConvexReactClient } from "convex/react";
import {
isRouteErrorResponse,
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "react-router";
import { authClient } from "@/lib/auth-client";
import type { Route } from "./+types/root";
import Header from "./components/header";
import { ThemeProvider } from "./components/theme-provider";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
export default function App() {
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
return (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
<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>
</ConvexBetterAuthProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
details =
error.status === 404 ? "The requested page could not be found." : error.statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
}
return (
<main className="pt-16 p-4 container mx-auto">
<h1>{message}</h1>
<p>{details}</p>
{stack && (
<pre className="w-full p-4 overflow-x-auto">
<code>{stack}</code>
</pre>
)}
</main>
);
}

4
apps/web/src/routes.ts Normal file
View File

@@ -0,0 +1,4 @@
import { type RouteConfig } from "@react-router/dev/routes";
import { flatRoutes } from "@react-router/fs-routes";
export default flatRoutes() satisfies RouteConfig;

View File

@@ -0,0 +1,51 @@
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import type { Route } from "./+types/_index";
const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗
╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
██║ ███████╗ ██║ ███████║██║ █████╔╝
██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗
██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
`;
export function meta({}: Route.MetaArgs) {
return [{ title: "code" }, { name: "description", content: "code is a web application" }];
}
export default function Home() {
const healthCheck = useQuery(api.healthCheck.get);
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>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-green-500" : healthCheck === undefined ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
</span>
</div>
</section>
</div>
</div>
);
}

View File

@@ -0,0 +1,41 @@
import { api } from "@code/backend/convex/_generated/api";
import { Authenticated, AuthLoading, Unauthenticated, useQuery } from "convex/react";
import { useState } from "react";
import SignInForm from "@/components/sign-in-form";
import SignUpForm from "@/components/sign-up-form";
import UserMenu from "@/components/user-menu";
function PrivateDashboardContent() {
const privateData = useQuery(api.privateData.get);
return (
<div>
<h1>Dashboard</h1>
<p>privateData: {privateData?.message}</p>
<UserMenu />
</div>
);
}
export default function Dashboard() {
const [showSignIn, setShowSignIn] = useState(false);
return (
<>
<Authenticated>
<PrivateDashboardContent />
</Authenticated>
<Unauthenticated>
{showSignIn ? (
<SignInForm onSwitchToSignUp={() => setShowSignIn(false)} />
) : (
<SignUpForm onSwitchToSignIn={() => setShowSignIn(true)} />
)}
</Unauthenticated>
<AuthLoading>
<div>Loading...</div>
</AuthLoading>
</>
);
}

View File

@@ -0,0 +1,102 @@
import { api } from "@code/backend/convex/_generated/api";
import type { Id } from "@code/backend/convex/_generated/dataModel";
import { Button } from "@code/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@code/ui/components/card";
import { Checkbox } from "@code/ui/components/checkbox";
import { Input } from "@code/ui/components/input";
import { useMutation, useQuery } from "convex/react";
import { Loader2, Trash2 } from "lucide-react";
import { useState, type FormEvent } from "react";
export default function Todos() {
const [newTodoText, setNewTodoText] = useState("");
const todos = useQuery(api.todos.getAll);
const createTodo = useMutation(api.todos.create);
const toggleTodo = useMutation(api.todos.toggle);
const deleteTodo = useMutation(api.todos.deleteTodo);
const handleAddTodo = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const text = newTodoText.trim();
if (!text) return;
await createTodo({ text });
setNewTodoText("");
};
const handleToggleTodo = (id: Id<"todos">, currentCompleted: boolean) => {
toggleTodo({ id, completed: !currentCompleted });
};
const handleDeleteTodo = (id: Id<"todos">) => {
deleteTodo({ id });
};
return (
<div className="w-full mx-auto max-w-md py-10">
<Card>
<CardHeader>
<CardTitle>Todo List</CardTitle>
<CardDescription>Manage your tasks efficiently</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleAddTodo} className="mb-6 flex items-center space-x-2">
<Input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add a new task..."
/>
<Button type="submit" disabled={!newTodoText.trim()}>
Add
</Button>
</form>
{todos === undefined ? (
<div className="flex justify-center py-4">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : todos.length === 0 ? (
<p className="py-4 text-center">No todos yet. Add one above!</p>
) : (
<ul className="space-y-2">
{todos.map((todo) => (
<li
key={todo._id}
className="flex items-center justify-between rounded-md border p-2"
>
<div className="flex items-center space-x-2">
<Checkbox
checked={todo.completed}
onCheckedChange={() => handleToggleTodo(todo._id, todo.completed)}
id={`todo-${todo._id}`}
/>
<label
htmlFor={`todo-${todo._id}`}
className={`${todo.completed ? "line-through text-muted-foreground" : ""}`}
>
{todo.text}
</label>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteTodo(todo._id)}
aria-label="Delete todo"
>
<Trash2 className="h-4 w-4" />
</Button>
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
);
}

22
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,22 @@
{
"include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"],
"compilerOptions": {
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"types": ["node", "vite/client"],
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"rootDirs": [".", "./.react-router/types"],
"paths": {
"@/*": ["./src/*"],
"@code/ui/*": ["../../packages/ui/src/*"]
},
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"strict": true
}
}

8
apps/web/vite.config.ts Normal file
View File

@@ -0,0 +1,8 @@
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite-plus";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), tsconfigPaths()],
});

31
bts.jsonc Normal file
View File

@@ -0,0 +1,31 @@
// Better-T-Stack
//
// Website: https://www.better-t-stack.dev/
// Stack Builder: https://www.better-t-stack.dev/new
// Analytics: https://www.better-t-stack.dev/analytics
// Showcase: https://www.better-t-stack.dev/showcase
// Sponsor: https://github.com/sponsors/AmanVarshney01
//
// Add new addons with: bun create better-t-stack add
// This file is safe to delete
{
"$schema": "https://r2.better-t-stack.dev/schema.json",
"version": "3.36.3",
"createdAt": "2026-07-18T21:14:36.246Z",
"reproducibleCommand": "bun create better-t-stack@latest code --frontend react-router native-uniwind --backend convex --runtime none --database none --orm none --api none --auth better-auth --payments none --addons electrobun mcp opentui oxlint skills ultracite vite-plus --examples todo --db-setup none --web-deploy none --server-deploy none --git --package-manager bun --install",
"database": "none",
"orm": "none",
"backend": "convex",
"runtime": "none",
"frontend": ["react-router", "native-uniwind"],
"addons": ["electrobun", "mcp", "opentui", "oxlint", "skills", "ultracite", "vite-plus"],
"examples": ["todo"],
"auth": "better-auth",
"payments": "none",
"packageManager": "bun",
"dbSetup": "none",
"api": "none",
"webDeploy": "none",
"serverDeploy": "none",
}

3147
bun.lock Normal file

File diff suppressed because it is too large Load Diff

38
opencode.json Normal file
View File

@@ -0,0 +1,38 @@
{
"mcp": {
"better-t-stack": {
"type": "local",
"command": ["bunx", "create-better-t-stack@latest", "mcp"],
"enabled": true,
"environment": {}
},
"context7": {
"type": "local",
"command": ["npx", "-y", "@upstash/context7-mcp"],
"enabled": true,
"environment": {}
},
"convex": {
"type": "local",
"command": ["npx", "-y", "convex@latest", "mcp", "start"],
"enabled": true,
"environment": {}
},
"shadcn": {
"type": "local",
"command": ["npx", "-y", "shadcn@latest", "mcp"],
"enabled": true,
"environment": {}
},
"better-auth": {
"type": "remote",
"url": "https://mcp.inkeep.com/better-auth/mcp",
"enabled": true
},
"expo-mcp": {
"type": "remote",
"url": "https://mcp.expo.dev/mcp",
"enabled": true
}
}
}

6
oxfmt.config.ts Normal file
View File

@@ -0,0 +1,6 @@
import { defineConfig } from "oxfmt";
import ultracite from "ultracite/oxfmt";
export default defineConfig({
...ultracite,
});

9
oxlint.config.ts Normal file
View File

@@ -0,0 +1,9 @@
import { defineConfig } from "oxlint";
import core from "ultracite/oxlint/core";
import react from "ultracite/oxlint/react";
import remix from "ultracite/oxlint/remix";
export default defineConfig({
extends: [core, react, remix],
ignorePatterns: core.ignorePatterns,
});

64
package.json Normal file
View File

@@ -0,0 +1,64 @@
{
"name": "code",
"private": true,
"workspaces": {
"packages": [
"apps/*",
"packages/*"
],
"catalog": {
"dotenv": "^17.4.2",
"zod": "^4.4.3",
"lucide-react": "^1.23.0",
"next-themes": "^0.4.6",
"sonner": "^2.0.7",
"convex": "^1.42.1",
"better-auth": "1.6.15",
"@convex-dev/better-auth": "^0.12.5",
"@tanstack/react-form": "^1.33.0",
"@types/react-dom": "^19.2.3",
"tailwindcss": "^4.3.2",
"tailwind-merge": "^3.6.0",
"@better-auth/expo": "1.6.15"
}
},
"type": "module",
"scripts": {
"dev": "vp run -r dev",
"build": "vp run -r build --filter '!desktop' && vp run --filter desktop build",
"check-types": "vp run -r check-types",
"check": "ultracite check",
"lint": "vp lint",
"format": "vp fmt",
"staged": "vp staged",
"hooks:setup": "vp config",
"dev:native": "vp run --filter native dev",
"dev:web": "vp run --filter web dev",
"dev:desktop": "vp run --filter desktop dev:hmr",
"build:desktop": "vp run --filter desktop build:stable",
"build:desktop:canary": "vp run --filter desktop build:canary",
"dev:tui": "vp run --filter tui dev",
"dev:server": "vp run --filter @code/backend dev",
"dev:setup": "vp run --filter @code/backend dev:setup",
"fix": "ultracite fix"
},
"dependencies": {
"@code/env": "workspace:*",
"dotenv": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/node": "^22.13.14",
"oxfmt": "latest",
"oxlint": "latest",
"rolldown": "1.1.4",
"typescript": "^6",
"ultracite": "7.9.3",
"vite-plus": "0.2.2"
},
"overrides": {
"vite": "npm:@voidzero-dev/vite-plus-core@0.2.2"
},
"packageManager": "bun@1.3.14"
}

2
packages/backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
.env.local

View File

@@ -0,0 +1,88 @@
# Welcome to your Convex functions directory!
Write your Convex functions here.
See https://docs.convex.dev/functions for more.
A query function that takes two arguments looks like:
```ts
// convex/myFunctions.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
export const myQueryFunction = query({
// Validators for arguments.
args: {
first: v.number(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Read the database as many times as you need here.
// See https://docs.convex.dev/database/reading-data.
const documents = await ctx.db.query("tablename").collect();
// Arguments passed from the client are properties of the args object.
console.log(args.first, args.second);
// Write arbitrary JavaScript here: filter, aggregate, build derived data,
// remove non-public properties, or create new objects.
return documents;
},
});
```
Using this query function in a React component looks like:
```ts
const data = useQuery(api.myFunctions.myQueryFunction, {
first: 10,
second: "hello",
});
```
A mutation function looks like:
```ts
// convex/myFunctions.ts
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const myMutationFunction = mutation({
// Validators for arguments.
args: {
first: v.string(),
second: v.string(),
},
// Function implementation.
handler: async (ctx, args) => {
// Insert or modify documents in the database here.
// Mutations can also read from the database like queries.
// See https://docs.convex.dev/database/writing-data.
const message = { body: args.first, author: args.second };
const id = await ctx.db.insert("messages", message);
// Optionally, return a value from your mutation.
return await ctx.db.get("messages", id);
},
});
```
Using this mutation function in a React component looks like:
```ts
const mutation = useMutation(api.myFunctions.myMutationFunction);
function handleButtonPress() {
// fire and forget, the most common way to use mutations
mutation({ first: "Hello!", second: "me" });
// OR
// use the result once the mutation has completed
mutation({ first: "Hello!", second: "me" }).then((result) => console.log(result));
}
```
Use the Convex CLI to push your functions to a deployment. See everything
the Convex CLI can do by running `npx convex -h` in your project root
directory. To learn more, launch the docs with `npx convex docs`.

View File

@@ -0,0 +1,4 @@
/* eslint-disable */
export declare const api: any;
export declare const internal: any;
export declare const components: any;

View File

@@ -0,0 +1,6 @@
/* eslint-disable */
import { anyApi, componentsGeneric } from "convex/server";
export const api = anyApi;
export const internal = anyApi;
export const components = componentsGeneric();

View File

@@ -0,0 +1,15 @@
/* eslint-disable */
import type {
DataModelFromSchemaDefinition,
DocumentByName,
SystemTableNames,
TableNamesInDataModel,
} from "convex/server";
import type { GenericId } from "convex/values";
import schema from "../schema.js";
export type DataModel = DataModelFromSchemaDefinition<typeof schema>;
export type TableNames = TableNamesInDataModel<DataModel>;
export type Doc<TableName extends TableNames> = DocumentByName<DataModel, TableName>;
export type Id<TableName extends TableNames | SystemTableNames> = GenericId<TableName>;

View File

@@ -0,0 +1,28 @@
/* eslint-disable */
import type {
ActionBuilder,
GenericActionCtx,
GenericDatabaseReader,
GenericDatabaseWriter,
GenericMutationCtx,
GenericQueryCtx,
HttpActionBuilder,
MutationBuilder,
QueryBuilder,
} from "convex/server";
import type { DataModel } from "./dataModel.js";
export declare const query: QueryBuilder<DataModel, "public">;
export declare const internalQuery: QueryBuilder<DataModel, "internal">;
export declare const mutation: MutationBuilder<DataModel, "public">;
export declare const internalMutation: MutationBuilder<DataModel, "internal">;
export declare const action: ActionBuilder<DataModel, "public">;
export declare const internalAction: ActionBuilder<DataModel, "internal">;
export declare const httpAction: HttpActionBuilder;
export type QueryCtx = GenericQueryCtx<DataModel>;
export type MutationCtx = GenericMutationCtx<DataModel>;
export type ActionCtx = GenericActionCtx<DataModel>;
export type DatabaseReader = GenericDatabaseReader<DataModel>;
export type DatabaseWriter = GenericDatabaseWriter<DataModel>;

View File

@@ -0,0 +1,18 @@
/* eslint-disable */
import {
actionGeneric,
httpActionGeneric,
internalActionGeneric,
internalMutationGeneric,
internalQueryGeneric,
mutationGeneric,
queryGeneric,
} from "convex/server";
export const query = queryGeneric;
export const internalQuery = internalQueryGeneric;
export const mutation = mutationGeneric;
export const internalMutation = internalMutationGeneric;
export const action = actionGeneric;
export const internalAction = internalActionGeneric;
export const httpAction = httpActionGeneric;

View File

@@ -0,0 +1,6 @@
import { getAuthConfigProvider } from "@convex-dev/better-auth/auth-config";
import type { AuthConfig } from "convex/server";
export default {
providers: [getAuthConfigProvider()],
} satisfies AuthConfig;

View File

@@ -0,0 +1,43 @@
import { expo } from "@better-auth/expo";
import { createClient, type GenericCtx } from "@convex-dev/better-auth";
import { convex, crossDomain } from "@convex-dev/better-auth/plugins";
import { betterAuth } from "better-auth/minimal";
import { components } from "./_generated/api";
import type { DataModel } from "./_generated/dataModel";
import { query } from "./_generated/server";
import authConfig from "./auth.config";
const siteUrl = process.env.SITE_URL!;
const nativeAppUrl = process.env.NATIVE_APP_URL || "code://";
export const authComponent = createClient<DataModel>(components.betterAuth);
function createAuth(ctx: GenericCtx<DataModel>) {
return betterAuth({
baseURL: process.env.CONVEX_SITE_URL,
trustedOrigins: [siteUrl, nativeAppUrl, "exp://"],
database: authComponent.adapter(ctx),
emailAndPassword: {
enabled: true,
requireEmailVerification: false,
},
plugins: [
expo(),
crossDomain({ siteUrl }),
convex({
authConfig,
jwksRotateOnTokenGenerationError: true,
}),
],
});
}
export { createAuth };
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
return await authComponent.safeGetAuthUser(ctx);
},
});

View File

@@ -0,0 +1,7 @@
import betterAuth from "@convex-dev/better-auth/convex.config";
import { defineApp } from "convex/server";
const app = defineApp();
app.use(betterAuth);
export default app;

View File

@@ -0,0 +1,7 @@
import { query } from "./_generated/server";
export const get = query({
handler: async () => {
return "OK";
},
});

View File

@@ -0,0 +1,9 @@
import { httpRouter } from "convex/server";
import { authComponent, createAuth } from "./auth";
const http = httpRouter();
authComponent.registerRoutes(http, createAuth, { cors: true });
export default http;

View File

@@ -0,0 +1,17 @@
import { query } from "./_generated/server";
import { authComponent } from "./auth";
export const get = query({
args: {},
handler: async (ctx) => {
const authUser = await authComponent.safeGetAuthUser(ctx);
if (!authUser) {
return {
message: "Not authenticated",
};
}
return {
message: "This is private",
};
},
});

View File

@@ -0,0 +1,9 @@
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
todos: defineTable({
text: v.string(),
completed: v.boolean(),
}),
});

View File

@@ -0,0 +1,43 @@
import { v } from "convex/values";
import { query, mutation } from "./_generated/server";
export const getAll = query({
handler: async (ctx) => {
return await ctx.db.query("todos").collect();
},
});
export const create = mutation({
args: {
text: v.string(),
},
handler: async (ctx, args) => {
const newTodoId = await ctx.db.insert("todos", {
text: args.text,
completed: false,
});
return await ctx.db.get("todos", newTodoId);
},
});
export const toggle = mutation({
args: {
id: v.id("todos"),
completed: v.boolean(),
},
handler: async (ctx, args) => {
await ctx.db.patch("todos", args.id, { completed: args.completed });
return { success: true };
},
});
export const deleteTodo = mutation({
args: {
id: v.id("todos"),
},
handler: async (ctx, args) => {
await ctx.db.delete("todos", args.id);
return { success: true };
},
});

View File

@@ -0,0 +1,26 @@
{
/* This TypeScript project config describes the environment that
* Convex functions run in and is used to typecheck them.
* You can modify it, but some settings are required to use Convex.
*/
"compilerOptions": {
/* These settings are not required by Convex and can be modified. */
"allowJs": true,
"strict": true,
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"skipLibCheck": true,
"allowSyntheticDefaultImports": true,
"types": ["node"],
/* These compiler options are required by Convex */
"target": "ESNext",
"lib": ["ES2021", "dom"],
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"isolatedModules": true,
"noEmit": true
},
"include": ["./**/*"],
"exclude": ["./_generated"]
}

View File

@@ -0,0 +1,24 @@
{
"name": "@code/backend",
"version": "1.0.0",
"description": "",
"license": "ISC",
"author": "",
"scripts": {
"dev": "convex dev",
"dev:setup": "convex dev --configure --until-success"
},
"dependencies": {
"@better-auth/expo": "catalog:",
"@convex-dev/better-auth": "catalog:",
"better-auth": "catalog:",
"convex": "catalog:",
"dotenv": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/node": "^24.3.0",
"typescript": "^6"
}
}

View File

@@ -0,0 +1,5 @@
{
"name": "@code/config",
"version": "0.0.0",
"private": true
}

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"verbatimModuleSyntax": true,
"strict": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
}
}

20
packages/env/package.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "@code/env",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./web": "./src/web.ts",
"./native": "./src/native.ts"
},
"dependencies": {
"@t3-oss/env-core": "^0.13.11",
"dotenv": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@code/config": "workspace:*",
"@types/node": "^22.13.14",
"typescript": "^6"
}
}

17
packages/env/src/native.ts vendored Normal file
View File

@@ -0,0 +1,17 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
const convexUrlSchema = (exampleHost: string) =>
z.url().refine((url) => new URL(url).hostname !== exampleHost, {
message: `Replace the ${exampleHost} placeholder before running the app`,
});
export const env = createEnv({
clientPrefix: "EXPO_PUBLIC_",
client: {
EXPO_PUBLIC_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
EXPO_PUBLIC_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
});

18
packages/env/src/web.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
const convexUrlSchema = (exampleHost: string) =>
z.url().refine((url) => new URL(url).hostname !== exampleHost, {
message: `Replace the ${exampleHost} placeholder before running the app`,
});
export const env = createEnv({
clientPrefix: "VITE_",
client: {
VITE_CONVEX_URL: convexUrlSchema("example.convex.cloud"),
VITE_CONVEX_SITE_URL: convexUrlSchema("example.convex.site"),
},
runtimeEnv: (import.meta as any).env,
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
emptyStringAsUndefined: true,
});

6
packages/env/tsconfig.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"extends": "@code/config/tsconfig.base.json",
"compilerOptions": {
"strictNullChecks": true
}
}

View File

@@ -0,0 +1,24 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-lyra",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@code/ui/components",
"utils": "@code/ui/lib/utils",
"hooks": "@code/ui/hooks",
"lib": "@code/ui/lib",
"ui": "@code/ui/components"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

38
packages/ui/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "@code/ui",
"version": "0.0.0",
"private": true,
"type": "module",
"exports": {
"./globals.css": "./src/styles/globals.css",
"./lib/*": "./src/lib/*.ts",
"./components/*": "./src/components/*.tsx",
"./hooks/*": "./src/hooks/*.ts",
"./postcss.config": "./postcss.config.mjs"
},
"scripts": {
"check-types": "tsc --noEmit"
},
"dependencies": {
"@base-ui/react": "^1.6.0",
"@shadcn/react": "^0.2.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "catalog:",
"next-themes": "catalog:",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"shadcn": "^4.12.0",
"sonner": "catalog:",
"tailwind-merge": "catalog:",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@code/config": "workspace:*",
"@tailwindcss/postcss": "^4.3.2",
"@types/react": "^19.2.17",
"@types/react-dom": "catalog:",
"tailwindcss": "catalog:",
"typescript": "^6"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View File

@@ -0,0 +1,198 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { Button } from "@code/ui/components/button";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
const attachmentVariants = cva(
"group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 flex-wrap rounded-none border bg-card text-card-foreground transition-colors focus-within:ring-1 focus-within:ring-ring/50 has-[>a,>button]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
{
variants: {
size: {
default:
"gap-2 text-xs has-data-[slot=attachment-content]:px-2 has-data-[slot=attachment-content]:py-1.5 has-data-[slot=attachment-media]:p-1.5",
sm: "gap-2.5 text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
xs: "gap-1.5 rounded-none text-xs has-data-[slot=attachment-content]:px-1.5 has-data-[slot=attachment-content]:py-1 has-data-[slot=attachment-media]:p-1",
},
orientation: {
horizontal: "min-w-40 items-center",
vertical: "w-24 flex-col has-data-[slot=attachment-content]:w-30",
},
},
},
);
function Attachment({
className,
state = "done",
size = "default",
orientation = "horizontal",
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof attachmentVariants> & {
state?: "idle" | "uploading" | "processing" | "error" | "done";
}) {
const resolvedOrientation = orientation ?? "horizontal";
return (
<div
data-slot="attachment"
data-state={state}
data-size={size}
data-orientation={resolvedOrientation}
className={cn(attachmentVariants({ size, orientation }), className)}
{...props}
/>
);
}
const attachmentMediaVariants = cva(
"relative flex aspect-square w-10 shrink-0 items-center justify-center overflow-hidden rounded-none bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[size=sm]/attachment:w-8 group-data-[size=xs]/attachment:w-7 group-data-[size=xs]/attachment:rounded-none group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[orientation=vertical]/attachment:*:data-[slot=spinner]:size-6! [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 group-data-[orientation=vertical]/attachment:[&_svg:not([class*='size-'])]:size-6 group-data-[size=xs]/attachment:[&_svg:not([class*='size-'])]:size-3.5",
{
variants: {
variant: {
icon: "",
image:
"opacity-60 group-data-[state=done]/attachment:opacity-100 group-data-[state=idle]/attachment:opacity-100 *:[img]:aspect-square *:[img]:w-full *:[img]:object-cover",
},
},
defaultVariants: {
variant: "icon",
},
},
);
function AttachmentMedia({
className,
variant = "icon",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof attachmentMediaVariants>) {
return (
<div
data-slot="attachment-media"
data-variant={variant}
className={cn(attachmentMediaVariants({ variant }), className)}
{...props}
/>
);
}
function AttachmentContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn(
"max-w-full min-w-0 flex-1 leading-tight group-data-[orientation=vertical]/attachment:px-1",
className,
)}
{...props}
/>
);
}
function AttachmentTitle({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"block max-w-full min-w-0 truncate font-medium group-data-[state=processing]/attachment:shimmer group-data-[state=uploading]/attachment:shimmer",
className,
)}
{...props}
/>
);
}
function AttachmentDescription({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"mt-0.5 block min-w-0 truncate text-xs text-muted-foreground group-data-[state=error]/attachment:text-destructive/80",
"max-w-full",
className,
)}
{...props}
/>
);
}
function AttachmentActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn(
"relative z-20 flex shrink-0 items-center group-data-[orientation=vertical]/attachment:absolute group-data-[orientation=vertical]/attachment:top-3 group-data-[orientation=vertical]/attachment:right-3 group-data-[orientation=vertical]/attachment:gap-1",
className,
)}
{...props}
/>
);
}
function AttachmentAction({
className,
variant,
size = "icon-xs",
type = "button",
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
type={type}
variant={variant ?? "ghost"}
size={size}
className={cn(className)}
{...props}
/>
);
}
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn("absolute inset-0 z-10 outline-none", className),
},
props,
),
render,
state: {
slot: "attachment-trigger",
},
});
}
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn(
"flex min-w-0 scroll-fade-x snap-x snap-mandatory scroll-px-1 scrollbar-none gap-3 overflow-x-auto overscroll-x-contain py-1 *:data-[slot=attachment]:flex-none *:data-[slot=attachment]:snap-start",
className,
)}
{...props}
/>
);
}
export {
Attachment,
AttachmentGroup,
AttachmentMedia,
AttachmentContent,
AttachmentTitle,
AttachmentDescription,
AttachmentActions,
AttachmentAction,
AttachmentTrigger,
};

View File

@@ -0,0 +1,123 @@
import { mergeProps } from "@base-ui/react/merge-props";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "@code/ui/lib/utils";
import { cva, type VariantProps } from "class-variance-authority";
import * as React from "react";
function BubbleGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="bubble-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
);
}
const bubbleVariants = cva(
"group/bubble relative flex w-fit max-w-[80%] min-w-0 flex-col gap-1 group-data-[align=end]/message:self-end data-[align=end]:self-end data-[variant=ghost]:max-w-full",
{
variants: {
variant: {
default:
"*:data-[slot=bubble-content]:bg-primary *:data-[slot=bubble-content]:text-primary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-primary/80",
secondary:
"*:data-[slot=bubble-content]:bg-secondary *:data-[slot=bubble-content]:text-secondary-foreground [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)]",
muted:
"*:data-[slot=bubble-content]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[color-mix(in_oklch,var(--muted),var(--foreground)_5%)]",
tinted:
"*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.93_calc(c*0.4)_h)] *:data-[slot=bubble-content]:text-foreground dark:*:data-[slot=bubble-content]:bg-[oklch(from_var(--primary)_0.3_calc(c*0.4)_h)] [&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.88_calc(c*0.5)_h)] dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-[oklch(from_var(--primary)_0.35_calc(c*0.5)_h)]",
outline:
"*:data-[slot=bubble-content]:border-border *:data-[slot=bubble-content]:bg-background [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-input/30",
ghost:
"border-none *:data-[slot=bubble-content]:rounded-none *:data-[slot=bubble-content]:bg-transparent *:data-[slot=bubble-content]:p-0 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted [&>[data-slot=bubble-content]:is(button,a):hover]:text-foreground dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-muted/50",
destructive:
"*:data-[slot=bubble-content]:bg-destructive/10 *:data-[slot=bubble-content]:text-destructive dark:*:data-[slot=bubble-content]:bg-destructive/20 [&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/20 dark:[&>[data-slot=bubble-content]:is(button,a):hover]:bg-destructive/30",
},
},
defaultVariants: {
variant: "default",
},
},
);
function Bubble({
variant = "default",
align = "start",
className,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof bubbleVariants> & {
align?: "start" | "end";
}) {
return (
<div
data-slot="bubble"
data-variant={variant}
data-align={align}
className={cn(bubbleVariants({ variant }), className)}
{...props}
/>
);
}
function BubbleContent({ className, render, ...props }: useRender.ComponentProps<"div">) {
return useRender({
defaultTagName: "div",
props: mergeProps<"div">(
{
className: cn(
"w-fit max-w-full min-w-0 overflow-hidden rounded-none border border-transparent px-2.5 py-2 text-xs leading-relaxed wrap-break-word group-data-[align=end]/bubble:self-end [button]:text-left [button,a]:transition-colors [button,a]:outline-none [button,a]:focus-visible:border-ring [button,a]:focus-visible:ring-1 [button,a]:focus-visible:ring-ring/50",
className,
),
},
props,
),
render,
state: {
slot: "bubble-content",
},
});
}
const bubbleReactionsVariants = cva(
"absolute z-10 flex w-fit shrink-0 items-center justify-center gap-1 rounded-none bg-muted px-1.5 py-0.5 text-xs ring-2 ring-card has-[button]:p-0",
{
variants: {
side: {
top: "top-0 -translate-y-3/4",
bottom: "bottom-0 translate-y-3/4",
},
align: {
start: "left-3",
end: "right-3",
},
},
defaultVariants: {
side: "bottom",
align: "end",
},
},
);
function BubbleReactions({
side = "bottom",
align = "end",
className,
...props
}: React.ComponentProps<"div"> & {
align?: "start" | "end";
side?: "top" | "bottom";
}) {
return (
<div
data-slot="bubble-reactions"
data-align={align}
data-side={side}
className={cn(bubbleReactionsVariants({ side, align }), className)}
{...props}
/>
);
}
export { BubbleGroup, Bubble, BubbleContent, BubbleReactions };

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