initial commit
23
.codex/config.toml
Normal 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
@@ -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
@@ -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
@@ -0,0 +1 @@
|
||||
artifacts
|
||||
35
apps/desktop/electrobun.config.ts
Normal 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
@@ -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"
|
||||
}
|
||||
}
|
||||
34
apps/desktop/src/bun/index.ts
Normal 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.");
|
||||
15
apps/desktop/tsconfig.json
Normal 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
@@ -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": "code",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"orientation": "default",
|
||||
"web": {
|
||||
"bundler": "metro"
|
||||
},
|
||||
"name": "code",
|
||||
"slug": "code",
|
||||
"plugins": ["expo-font"],
|
||||
"experiments": {
|
||||
"typedRoutes": true,
|
||||
"reactCompiler": true
|
||||
}
|
||||
}
|
||||
}
|
||||
42
apps/native/app/(drawer)/(tabs)/_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
88
apps/native/app/(drawer)/_layout.tsx
Normal 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;
|
||||
72
apps/native/app/(drawer)/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
149
apps/native/app/(drawer)/todos.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
44
apps/native/app/_layout.tsx
Normal 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
@@ -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>
|
||||
);
|
||||
}
|
||||
164
apps/native/components/sign-in.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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';
|
||||
21
apps/native/lib/auth-client.ts
Normal 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,
|
||||
}),
|
||||
],
|
||||
});
|
||||
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;
|
||||
60
apps/native/package.json
Normal 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
@@ -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
@@ -0,0 +1,3 @@
|
||||
/// <reference types="uniwind/types" />
|
||||
|
||||
declare module "*.css";
|
||||
34
apps/tui/.gitignore
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "@code/ui/lib/utils",
|
||||
"ui": "@code/ui/components",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
45
apps/web/package.json
Normal 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
|
After Width: | Height: | Size: 15 KiB |
7
apps/web/react-router.config.ts
Normal 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;
|
||||
36
apps/web/src/components/header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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 "@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>
|
||||
);
|
||||
}
|
||||
127
apps/web/src/components/sign-in-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/web/src/components/sign-up-form.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
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";
|
||||
47
apps/web/src/components/user-menu.tsx
Normal 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
@@ -0,0 +1 @@
|
||||
@import "@code/ui/globals.css";
|
||||
8
apps/web/src/lib/auth-client.ts
Normal 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
@@ -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
@@ -0,0 +1,4 @@
|
||||
import { type RouteConfig } from "@react-router/dev/routes";
|
||||
import { flatRoutes } from "@react-router/fs-routes";
|
||||
|
||||
export default flatRoutes() satisfies RouteConfig;
|
||||
51
apps/web/src/routes/_index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
41
apps/web/src/routes/dashboard.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
102
apps/web/src/routes/todos.tsx
Normal 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
@@ -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
@@ -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
@@ -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",
|
||||
}
|
||||
38
opencode.json
Normal 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
@@ -0,0 +1,6 @@
|
||||
import { defineConfig } from "oxfmt";
|
||||
import ultracite from "ultracite/oxfmt";
|
||||
|
||||
export default defineConfig({
|
||||
...ultracite,
|
||||
});
|
||||
9
oxlint.config.ts
Normal 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
@@ -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
@@ -0,0 +1,2 @@
|
||||
|
||||
.env.local
|
||||
88
packages/backend/convex/README.md
Normal 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`.
|
||||
4
packages/backend/convex/_generated/api.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
/* eslint-disable */
|
||||
export declare const api: any;
|
||||
export declare const internal: any;
|
||||
export declare const components: any;
|
||||
6
packages/backend/convex/_generated/api.js
Normal 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();
|
||||
15
packages/backend/convex/_generated/dataModel.d.ts
vendored
Normal 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>;
|
||||
28
packages/backend/convex/_generated/server.d.ts
vendored
Normal 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>;
|
||||
18
packages/backend/convex/_generated/server.js
Normal 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;
|
||||
6
packages/backend/convex/auth.config.ts
Normal 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;
|
||||
43
packages/backend/convex/auth.ts
Normal 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);
|
||||
},
|
||||
});
|
||||
7
packages/backend/convex/convex.config.ts
Normal 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;
|
||||
7
packages/backend/convex/healthCheck.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { query } from "./_generated/server";
|
||||
|
||||
export const get = query({
|
||||
handler: async () => {
|
||||
return "OK";
|
||||
},
|
||||
});
|
||||
9
packages/backend/convex/http.ts
Normal 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;
|
||||
17
packages/backend/convex/privateData.ts
Normal 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",
|
||||
};
|
||||
},
|
||||
});
|
||||
9
packages/backend/convex/schema.ts
Normal 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(),
|
||||
}),
|
||||
});
|
||||
43
packages/backend/convex/todos.ts
Normal 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 };
|
||||
},
|
||||
});
|
||||
26
packages/backend/convex/tsconfig.json
Normal 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"]
|
||||
}
|
||||
24
packages/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
5
packages/config/package.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"name": "@code/config",
|
||||
"version": "0.0.0",
|
||||
"private": true
|
||||
}
|
||||
22
packages/config/tsconfig.base.json
Normal 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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@code/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"strictNullChecks": true
|
||||
}
|
||||
}
|
||||
24
packages/ui/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": "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
@@ -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"
|
||||
}
|
||||
}
|
||||
5
packages/ui/postcss.config.mjs
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
198
packages/ui/src/components/attachment.tsx
Normal 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,
|
||||
};
|
||||
123
packages/ui/src/components/bubble.tsx
Normal 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 };
|
||||