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