├── .npmrc ├── apps ├── web │ ├── project.inlang │ │ ├── .gitignore │ │ ├── project_id │ │ └── settings.json │ ├── public │ │ └── robots.txt │ ├── .vscode │ │ └── extensions.json │ ├── postcss.config.ts │ ├── .env.development.example │ ├── .env.production.example │ ├── src │ │ ├── lib │ │ │ ├── auth │ │ │ │ └── auth-client.ts │ │ │ ├── utils.ts │ │ │ └── cookies.ts │ │ ├── utils │ │ │ ├── prerender.ts │ │ │ ├── seo.ts │ │ │ ├── orpc.ts │ │ │ └── translated-pathnames.ts │ │ ├── components │ │ │ ├── loader.tsx │ │ │ ├── ui │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── separator.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── avatar.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── toggle.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ ├── toggle-group.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── breadcrumb.tsx │ │ │ │ ├── table.tsx │ │ │ │ ├── alert-dialog.tsx │ │ │ │ └── dialog.tsx │ │ │ ├── skip-to-main.tsx │ │ │ ├── sign-out-dialog.tsx │ │ │ ├── site-header.tsx │ │ │ ├── layout │ │ │ │ ├── types.ts │ │ │ │ ├── app-sidebar.tsx │ │ │ │ ├── authenticated-layout.tsx │ │ │ │ ├── team-switcher.tsx │ │ │ │ └── nav-user.tsx │ │ │ ├── header.tsx │ │ │ ├── nav-secondary.tsx │ │ │ ├── context │ │ │ │ ├── search-provider.tsx │ │ │ │ ├── layout-provider.tsx │ │ │ │ └── theme-provider.tsx │ │ │ ├── nav-main.tsx │ │ │ ├── language-switcher.tsx │ │ │ ├── user-menu.tsx │ │ │ ├── confirm-dialog.tsx │ │ │ ├── theme-switch.tsx │ │ │ ├── nav-documents.tsx │ │ │ ├── command-menu.tsx │ │ │ ├── nav-user.tsx │ │ │ ├── section-cards.tsx │ │ │ └── sign-in-form.tsx │ │ ├── server.ts │ │ ├── routes │ │ │ ├── _authenticated │ │ │ │ └── (dashboard) │ │ │ │ │ ├── route.tsx │ │ │ │ │ └── dashboard.tsx │ │ │ ├── _public │ │ │ │ ├── terms.tsx │ │ │ │ ├── privacy.tsx │ │ │ │ ├── route.tsx │ │ │ │ └── index.tsx │ │ │ ├── (auth) │ │ │ │ └── auth │ │ │ │ │ ├── route.tsx │ │ │ │ │ ├── callback.tsx │ │ │ │ │ └── $authView.tsx │ │ │ └── __root.tsx │ │ ├── configs │ │ │ └── web-config.ts │ │ ├── hooks │ │ │ ├── use-dialog-state.tsx │ │ │ ├── use-sign-out.ts │ │ │ └── use-mobile.ts │ │ ├── router.tsx │ │ └── providers.tsx │ ├── wrangler.jsonc │ ├── components.json │ ├── tsconfig.json │ ├── messages │ │ ├── zh.json │ │ └── en.json │ ├── .gitignore │ ├── vite.config.ts │ └── package.json ├── native │ ├── .env.example │ ├── assets │ │ └── images │ │ │ ├── icon.png │ │ │ ├── favicon.png │ │ │ ├── react-logo.png │ │ │ ├── splash-icon.png │ │ │ ├── react-logo@2x.png │ │ │ ├── react-logo@3x.png │ │ │ ├── partial-react-logo.png │ │ │ ├── android-icon-background.png │ │ │ ├── android-icon-foreground.png │ │ │ └── android-icon-monochrome.png │ ├── nativewind-env.d.ts │ ├── components │ │ ├── tabbar-icon.tsx │ │ ├── container.tsx │ │ ├── header-button.tsx │ │ ├── sign-in.tsx │ │ └── sign-up.tsx │ ├── babel.config.js │ ├── .gitignore │ ├── lib │ │ ├── use-color-scheme.ts │ │ ├── auth-client.ts │ │ ├── android-navigation-bar.tsx │ │ └── constants.ts │ ├── tsconfig.json │ ├── app │ │ ├── modal.tsx │ │ ├── (drawer) │ │ │ ├── (tabs) │ │ │ │ ├── two.tsx │ │ │ │ ├── index.tsx │ │ │ │ └── _layout.tsx │ │ │ ├── _layout.tsx │ │ │ └── index.tsx │ │ ├── +not-found.tsx │ │ └── _layout.tsx │ ├── utils │ │ └── orpc.ts │ ├── metro.config.js │ ├── app.json │ ├── global.css │ ├── package.json │ └── tailwind.config.js └── server │ ├── drizzle.config.ts │ ├── src │ ├── db │ │ ├── migrations │ │ │ ├── meta │ │ │ │ └── _journal.json │ │ │ └── 0000_tiresome_rawhide_kid.sql │ │ ├── index.ts │ │ └── schema │ │ │ ├── tenant.ts │ │ │ └── auth.ts │ ├── middlewares │ │ ├── error.ts │ │ ├── session.ts │ │ └── cors.ts │ ├── handlers │ │ ├── rpc.ts │ │ └── api.ts │ ├── routers │ │ └── index.ts │ ├── lib │ │ ├── context.ts │ │ ├── orpc.ts │ │ └── auth.ts │ ├── utils │ │ └── tenant.ts │ └── index.ts │ ├── .env.example │ ├── .prod.vars.example │ ├── tsconfig.json │ ├── .dev.vars.example │ ├── wrangler.jsonc │ ├── .gitignore │ ├── package.json │ ├── README.md │ └── scripts │ └── setup-secrets.sh ├── pnpm-workspace.yaml ├── tsconfig.json ├── .vscode ├── extensions.json └── settings.json ├── bts.jsonc ├── turbo.json ├── .gitignore ├── .ruler ├── ruler.toml └── bts.md ├── package.json ├── biome.json ├── .husky └── pre-commit ├── .github └── workflows │ └── preview.yml └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | node-linker=hoisted 2 | -------------------------------------------------------------------------------- /apps/web/project.inlang/.gitignore: -------------------------------------------------------------------------------- 1 | cache -------------------------------------------------------------------------------- /apps/native/.env.example: -------------------------------------------------------------------------------- 1 | EXPO_PUBLIC_SERVER_URL= -------------------------------------------------------------------------------- /apps/web/project.inlang/project_id: -------------------------------------------------------------------------------- 1 | C6s5y6B8Eym03VULF2 -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "apps/*" 3 | - "packages/*" 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strictNullChecks": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "inlang.vs-code-extension" 4 | ] 5 | } -------------------------------------------------------------------------------- /apps/web/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /apps/web/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "inlang.vs-code-extension" 4 | ] 5 | } -------------------------------------------------------------------------------- /apps/web/postcss.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | "@tailwindcss/postcss": {}, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /apps/native/assets/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/icon.png -------------------------------------------------------------------------------- /apps/native/assets/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/favicon.png -------------------------------------------------------------------------------- /apps/native/assets/images/react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/react-logo.png -------------------------------------------------------------------------------- /apps/native/assets/images/splash-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/splash-icon.png -------------------------------------------------------------------------------- /apps/native/assets/images/react-logo@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/react-logo@2x.png -------------------------------------------------------------------------------- /apps/native/assets/images/react-logo@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/react-logo@3x.png -------------------------------------------------------------------------------- /apps/native/assets/images/partial-react-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/partial-react-logo.png -------------------------------------------------------------------------------- /apps/native/assets/images/android-icon-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/android-icon-background.png -------------------------------------------------------------------------------- /apps/native/assets/images/android-icon-foreground.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/android-icon-foreground.png -------------------------------------------------------------------------------- /apps/native/assets/images/android-icon-monochrome.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunshineLixun/ShipFullStack/HEAD/apps/native/assets/images/android-icon-monochrome.png -------------------------------------------------------------------------------- /apps/web/.env.development.example: -------------------------------------------------------------------------------- 1 | # Dev Server URL 2 | VITE_SERVER_URL=http://localhost:15000 3 | 4 | # Dev Frontend URL (for OAuth callbacks) 5 | VITE_APP_URL=http://localhost:15001 6 | -------------------------------------------------------------------------------- /apps/native/nativewind-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | // NOTE: This file should not be edited and should be committed with your source code. It is generated by NativeWind. 4 | -------------------------------------------------------------------------------- /apps/web/.env.production.example: -------------------------------------------------------------------------------- 1 | # Production Server URL 2 | VITE_SERVER_URL=your-production-server-url 3 | 4 | # Production Frontend URL (for OAuth callbacks) 5 | VITE_APP_URL=your-production-app-url 6 | -------------------------------------------------------------------------------- /apps/web/src/lib/auth/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { createAuthClient } from "better-auth/react"; 2 | 3 | export const authClient = createAuthClient({ 4 | baseURL: import.meta.env.VITE_SERVER_URL, 5 | }); 6 | -------------------------------------------------------------------------------- /apps/web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { type ClassValue, clsx } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/src/utils/prerender.ts: -------------------------------------------------------------------------------- 1 | import { localizeHref } from "@/paraglide/runtime.js"; 2 | 3 | export const prerenderRoutes = ["/", "/terms", "/privacy"].map((path) => ({ 4 | path: localizeHref(path), 5 | prerender: { 6 | enabled: true, 7 | }, 8 | })); 9 | -------------------------------------------------------------------------------- /apps/web/src/components/loader.tsx: -------------------------------------------------------------------------------- 1 | import { Loader2 } from "lucide-react"; 2 | 3 | export default function Loader() { 4 | return ( 5 |
6 | 7 |
8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /apps/native/components/tabbar-icon.tsx: -------------------------------------------------------------------------------- 1 | import FontAwesome from "@expo/vector-icons/FontAwesome"; 2 | 3 | export const TabBarIcon = (props: { 4 | name: React.ComponentProps["name"]; 5 | color: string; 6 | }) => ; 7 | -------------------------------------------------------------------------------- /apps/server/drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "drizzle-kit"; 2 | 3 | export default defineConfig({ 4 | schema: "./src/db/schema", 5 | out: "./src/db/migrations", 6 | dialect: "postgresql", 7 | dbCredentials: { 8 | url: process.env.DATABASE_URL || "", 9 | }, 10 | }); 11 | -------------------------------------------------------------------------------- /apps/native/components/container.tsx: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { SafeAreaView } from "react-native-safe-area-context"; 3 | 4 | export const Container = ({ children }: { children: React.ReactNode }) => ( 5 | {children} 6 | ); 7 | -------------------------------------------------------------------------------- /apps/web/src/server.ts: -------------------------------------------------------------------------------- 1 | import handler from "@tanstack/react-start/server-entry"; 2 | import { paraglideMiddleware } from "./paraglide/server.js"; 3 | 4 | export default { 5 | fetch(req: Request): Promise { 6 | return paraglideMiddleware(req, ({ request }) => handler.fetch(request)); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /apps/web/src/routes/_authenticated/(dashboard)/route.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { AuthenticatedLayout } from "@/components/layout/authenticated-layout"; 3 | 4 | export const Route = createFileRoute("/_authenticated/(dashboard)")({ 5 | component: AuthenticatedLayout, 6 | }); 7 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/meta/_journal.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "7", 3 | "dialect": "postgresql", 4 | "entries": [ 5 | { 6 | "idx": 0, 7 | "version": "7", 8 | "when": 1760545349318, 9 | "tag": "0000_tiresome_rawhide_kid", 10 | "breakpoints": true 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /apps/server/src/middlewares/error.ts: -------------------------------------------------------------------------------- 1 | import type { ErrorHandler } from "hono"; 2 | 3 | export const errorHandler: ErrorHandler = (err, c) => { 4 | console.error("Unhandled error:", err); 5 | return c.json( 6 | { 7 | error: "Internal Server Error", 8 | message: err.message, 9 | }, 10 | 500 11 | ); 12 | }; 13 | -------------------------------------------------------------------------------- /apps/native/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache(true); 3 | const plugins = []; 4 | 5 | plugins.push("react-native-worklets/plugin"); 6 | 7 | return { 8 | presets: [ 9 | ["babel-preset-expo", { jsxImportSource: "nativewind" }], 10 | "nativewind/babel", 11 | ], 12 | plugins, 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /apps/server/src/handlers/rpc.ts: -------------------------------------------------------------------------------- 1 | import { onError } from "@orpc/server"; 2 | import { RPCHandler } from "@orpc/server/fetch"; 3 | import { appRouter } from "../routers/index"; 4 | 5 | export const rpcHandler = new RPCHandler(appRouter, { 6 | interceptors: [ 7 | onError((error) => { 8 | console.error(error); 9 | }), 10 | ], 11 | }); 12 | -------------------------------------------------------------------------------- /apps/web/src/routes/_public/terms.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { m } from "@/paraglide/messages"; 3 | 4 | export const Route = createFileRoute("/_public/terms")({ 5 | component: RouteComponent, 6 | }); 7 | 8 | function RouteComponent() { 9 | return
{m.example_message({ username: "terms" })}
; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/routes/_public/privacy.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute } from "@tanstack/react-router"; 2 | import { m } from "@/paraglide/messages"; 3 | 4 | export const Route = createFileRoute("/_public/privacy")({ 5 | component: RouteComponent, 6 | }); 7 | 8 | function RouteComponent() { 9 | return
{m.example_message({ username: "privacy" })}
; 10 | } 11 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils"; 2 | 3 | function Skeleton({ className, ...props }: React.ComponentProps<"div">) { 4 | return ( 5 |
10 | ); 11 | } 12 | 13 | export { Skeleton }; 14 | -------------------------------------------------------------------------------- /apps/web/src/routes/_public/route.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, Outlet } from "@tanstack/react-router"; 2 | import Header from "@/components/header"; 3 | 4 | export const Route = createFileRoute("/_public")({ 5 | component: RouteComponent, 6 | }); 7 | 8 | function RouteComponent() { 9 | return ( 10 | <> 11 |
12 | 13 | 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /apps/native/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | # expo router 13 | expo-env.d.ts 14 | 15 | .env 16 | .cache 17 | 18 | ios 19 | android 20 | 21 | # macOS 22 | .DS_Store 23 | 24 | # Temporary files created by Metro to check the health of the file watcher 25 | .metro-health-check* 26 | -------------------------------------------------------------------------------- /apps/native/lib/use-color-scheme.ts: -------------------------------------------------------------------------------- 1 | import { useColorScheme as useNativewindColorScheme } from "nativewind"; 2 | 3 | export function useColorScheme() { 4 | const { colorScheme, setColorScheme, toggleColorScheme } = 5 | useNativewindColorScheme(); 6 | return { 7 | colorScheme: colorScheme ?? "dark", 8 | isDarkColorScheme: colorScheme === "dark", 9 | setColorScheme, 10 | toggleColorScheme, 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /apps/native/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "expo/tsconfig.base", 3 | "compilerOptions": { 4 | "strict": true, 5 | "paths": { 6 | "@/*": ["./*"] 7 | } 8 | }, 9 | "include": [ 10 | "**/*.ts", 11 | "**/*.tsx", 12 | ".expo/types/**/*.ts", 13 | "expo-env.d.ts", 14 | "nativewind-env.d.ts" 15 | ], 16 | "references": [ 17 | { 18 | "path": "../server" 19 | } 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /apps/web/project.inlang/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/project-settings", 3 | "baseLocale": "en", 4 | "locales": ["en", "zh"], 5 | "modules": [ 6 | "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js", 7 | "https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js" 8 | ], 9 | "plugin.inlang.messageFormat": { 10 | "pathPattern": "./messages/{locale}.json" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /apps/native/app/modal.tsx: -------------------------------------------------------------------------------- 1 | import { Text, View } from "react-native"; 2 | import { Container } from "@/components/container"; 3 | 4 | export default function Modal() { 5 | return ( 6 | 7 | 8 | 9 | Modal 10 | 11 | 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/src/components/skip-to-main.tsx: -------------------------------------------------------------------------------- 1 | export function SkipToMain() { 2 | return ( 3 | 9 | Skip to Main 10 | 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /apps/server/.env.example: -------------------------------------------------------------------------------- 1 | # Claimable DB expires at: Fri, 03 Oct 2025 15:24:09 GMT 2 | # Claim it now to your account: https://neon.new/database/0237aece-afd8-42a2-b5ae-7760636c4e73 3 | 4 | # Database 5 | DATABASE_URL= 6 | DATABASE_URL_POOLER= 7 | CORS_ORIGIN= 8 | 9 | # Better Auth 10 | BETTER_AUTH_SECRET= 11 | BETTER_AUTH_URL= 12 | 13 | # Github OAuth 14 | GITHUB_CLIENT_ID="" 15 | GITHUB_CLIENT_SECRET="" 16 | 17 | 18 | # Google OAuth 19 | GOOGLE_CLIENT_ID="" 20 | GOOGLE_CLIENT_SECRET="" -------------------------------------------------------------------------------- /apps/native/lib/auth-client.ts: -------------------------------------------------------------------------------- 1 | import { expoClient } from "@better-auth/expo/client"; 2 | import { createAuthClient } from "better-auth/react"; 3 | import * as SecureStore from "expo-secure-store"; 4 | 5 | export const authClient = createAuthClient({ 6 | baseURL: process.env.EXPO_PUBLIC_SERVER_URL, 7 | plugins: [ 8 | expoClient({ 9 | scheme: "mybettertapp", 10 | storagePrefix: "easyapp-fullstack-template", 11 | storage: SecureStore, 12 | }), 13 | ], 14 | }); 15 | -------------------------------------------------------------------------------- /apps/server/.prod.vars.example: -------------------------------------------------------------------------------- 1 | # Production environment secrets for Cloudflare Workers 2 | # This file is used by `pnpm run secrets:setup` to set production secrets 3 | # Copy this file to .prod.vars and fill in your actual production values 4 | 5 | # Database (Production) 6 | DATABASE_URL= 7 | DATABASE_URL_POOLER= 8 | 9 | # Better Auth (Production) 10 | BETTER_AUTH_SECRET= 11 | 12 | # Github OAuth (Production) 13 | GITHUB_CLIENT_SECRET= 14 | 15 | # Google OAuth (Production) 16 | GOOGLE_CLIENT_SECRET= 17 | -------------------------------------------------------------------------------- /apps/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "moduleResolution": "bundler", 6 | "verbatimModuleSyntax": true, 7 | "strict": true, 8 | "skipLibCheck": true, 9 | "baseUrl": "./", 10 | "paths": { 11 | "@/*": ["./src/*"] 12 | }, 13 | "outDir": "./dist", 14 | "types": ["./worker-configuration.d.ts", "node"], 15 | "composite": true, 16 | "jsx": "react-jsx", 17 | "jsxImportSource": "hono/jsx" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/wrangler/config-schema.json", 3 | "name": "ship-fullstack", 4 | "compatibility_date": "2025-09-02", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "main": "@tanstack/react-start/server-entry", 7 | "assets": { 8 | "not_found_handling": "single-page-application" 9 | }, 10 | "vars": { 11 | "VITE_SERVER_URL": "https://ship-fullstack-server.ship-fullstack.workers.dev", 12 | "VITE_APP_URL": "https://ship-fullstack.ship-fullstack.workers.dev" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /apps/web/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "", 8 | "css": "src/index.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | }, 20 | "iconLibrary": "lucide" 21 | } 22 | -------------------------------------------------------------------------------- /apps/server/src/db/index.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { neon, neonConfig } from "@neondatabase/serverless"; 3 | import { drizzle } from "drizzle-orm/neon-http"; 4 | import ws from "ws"; 5 | 6 | neonConfig.webSocketConstructor = ws; 7 | 8 | // To work in edge environments (Cloudflare Workers, Vercel Edge, etc.), enable querying over fetch 9 | // neonConfig.poolQueryViaFetch = true 10 | 11 | // const sql = neon(process.env.DATABASE_URL || ""); 12 | const sql = neon(env.DATABASE_URL || ""); 13 | export const db = drizzle(sql); 14 | -------------------------------------------------------------------------------- /apps/web/src/configs/web-config.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@/paraglide/runtime"; 2 | 3 | interface WebConfig { 4 | i18n: { 5 | locales: Record; 6 | }; 7 | } 8 | 9 | export const webConfig: WebConfig = { 10 | i18n: { 11 | locales: { 12 | en: { 13 | flag: "🇺🇸", 14 | name: "English", 15 | locale: "en", 16 | }, 17 | zh: { 18 | flag: "🇨🇳", 19 | name: "中文", 20 | locale: "zh", 21 | }, 22 | }, 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /apps/native/lib/android-navigation-bar.tsx: -------------------------------------------------------------------------------- 1 | import * as NavigationBar from "expo-navigation-bar"; 2 | import { Platform } from "react-native"; 3 | import { NAV_THEME } from "@/lib/constants"; 4 | 5 | export async function setAndroidNavigationBar(theme: "light" | "dark") { 6 | if (Platform.OS !== "android") { 7 | return; 8 | } 9 | await NavigationBar.setButtonStyleAsync(theme === "dark" ? "light" : "dark"); 10 | await NavigationBar.setBackgroundColorAsync( 11 | theme === "dark" ? NAV_THEME.dark.background : NAV_THEME.light.background 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /apps/native/lib/constants.ts: -------------------------------------------------------------------------------- 1 | export const NAV_THEME = { 2 | light: { 3 | background: "hsl(0 0% 100%)", 4 | border: "hsl(220 13% 91%)", 5 | card: "hsl(0 0% 100%)", 6 | notification: "hsl(0 84.2% 60.2%)", 7 | primary: "hsl(221.2 83.2% 53.3%)", 8 | text: "hsl(222.2 84% 4.9%)", 9 | }, 10 | dark: { 11 | background: "hsl(222.2 84% 4.9%)", 12 | border: "hsl(217.2 32.6% 17.5%)", 13 | card: "hsl(222.2 84% 4.9%)", 14 | notification: "hsl(0 72% 51%)", 15 | primary: "hsl(217.2 91.2% 59.8%)", 16 | text: "hsl(210 40% 98%)", 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /apps/server/.dev.vars.example: -------------------------------------------------------------------------------- 1 | # Local development environment variables for Wrangler 2 | # This file is automatically loaded by `wrangler dev` 3 | # Copy this file to .dev.vars and fill in your actual values 4 | 5 | NODE_ENV=development 6 | CORS_ORIGIN=http://localhost:15001 7 | 8 | # Database 9 | DATABASE_URL= 10 | DATABASE_URL_POOLER= 11 | 12 | # Better Auth 13 | BETTER_AUTH_SECRET= 14 | BETTER_AUTH_URL=http://localhost:15000 15 | 16 | # Github OAuth 17 | GITHUB_CLIENT_ID= 18 | GITHUB_CLIENT_SECRET= 19 | 20 | # Google OAuth 21 | GOOGLE_CLIENT_ID= 22 | GOOGLE_CLIENT_SECRET= 23 | -------------------------------------------------------------------------------- /apps/server/src/routers/index.ts: -------------------------------------------------------------------------------- 1 | import type { RouterClient } from "@orpc/server"; 2 | import { publicProcedure, tenantProcedure } from "../lib/orpc"; 3 | 4 | export const appRouter = { 5 | healthCheck: publicProcedure.handler(() => "OK"), 6 | privateData: tenantProcedure.handler(({ context }) => ({ 7 | message: "This is private", 8 | user: context.session?.user, 9 | tenant: context.tenant, 10 | tenantMembership: context.tenantMembership, 11 | })), 12 | }; 13 | export type AppRouter = typeof appRouter; 14 | export type AppRouterClient = RouterClient; 15 | -------------------------------------------------------------------------------- /apps/server/src/handlers/api.ts: -------------------------------------------------------------------------------- 1 | import { OpenAPIHandler } from "@orpc/openapi/fetch"; 2 | import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; 3 | import { onError } from "@orpc/server"; 4 | import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4"; 5 | import { appRouter } from "../routers/index"; 6 | 7 | export const apiHandler = new OpenAPIHandler(appRouter, { 8 | plugins: [ 9 | new OpenAPIReferencePlugin({ 10 | schemaConverters: [new ZodToJsonSchemaConverter()], 11 | }), 12 | ], 13 | interceptors: [ 14 | onError((error) => { 15 | console.error(error); 16 | }), 17 | ], 18 | }); 19 | -------------------------------------------------------------------------------- /apps/server/src/middlewares/session.ts: -------------------------------------------------------------------------------- 1 | import type { Context, Next } from "hono"; 2 | import { auth } from "../lib/auth"; 3 | 4 | export const sessionMiddleware = async (c: Context, next: Next) => { 5 | // Skip auth check for auth endpoints 6 | if (c.req.path.startsWith("/api/auth/")) { 7 | return next(); 8 | } 9 | 10 | const session = await auth.api.getSession({ headers: c.req.raw.headers }); 11 | if (!session) { 12 | c.set("user", null); 13 | c.set("session", null); 14 | return next(); 15 | } 16 | c.set("user", session.user); 17 | c.set("session", session.session); 18 | return next(); 19 | }; 20 | -------------------------------------------------------------------------------- /apps/native/app/(drawer)/(tabs)/two.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollView, Text, View } from "react-native"; 2 | import { Container } from "@/components/container"; 3 | 4 | export default function TabTwo() { 5 | return ( 6 | 7 | 8 | 9 | 10 | Tab Two 11 | 12 | 13 | Discover more features and content 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/hooks/use-dialog-state.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | /** 4 | * Custom hook for confirm dialog 5 | * @param initialState string | null 6 | * @returns A stateful value, and a function to update it. 7 | * @example const [open, setOpen] = useDialogState<"approve" | "reject">() 8 | */ 9 | export default function useDialogState( 10 | initialState: T | null = null 11 | ) { 12 | const [open, _setOpen] = useState(initialState); 13 | 14 | const setOpen = (str: T | null) => 15 | _setOpen((prev) => (prev === str ? null : str)); 16 | 17 | return [open, setOpen] as const; 18 | } 19 | -------------------------------------------------------------------------------- /apps/native/app/(drawer)/(tabs)/index.tsx: -------------------------------------------------------------------------------- 1 | import { ScrollView, Text, View } from "react-native"; 2 | import { Container } from "@/components/container"; 3 | 4 | export default function TabOne() { 5 | return ( 6 | 7 | 8 | 9 | 10 | Tab One 11 | 12 | 13 | Explore the first section of your app 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /apps/web/src/hooks/use-sign-out.ts: -------------------------------------------------------------------------------- 1 | import { useNavigate } from "@tanstack/react-router"; 2 | import { toast } from "sonner"; 3 | import { authClient } from "@/lib/auth/auth-client"; 4 | 5 | export const useSignOut = () => { 6 | const navigate = useNavigate(); 7 | 8 | const signOut = () => { 9 | authClient.signOut({ 10 | fetchOptions: { 11 | onSuccess: () => { 12 | navigate({ to: "/" }); 13 | }, 14 | onError: (error) => { 15 | console.error(error); 16 | toast.error(error.error.message || error.error.statusText); 17 | }, 18 | }, 19 | }); 20 | }; 21 | 22 | return { 23 | signOut, 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/src/hooks/use-mobile.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | 3 | const MOBILE_BREAKPOINT = 768; 4 | 5 | export function useIsMobile() { 6 | const [isMobile, setIsMobile] = React.useState( 7 | undefined 8 | ); 9 | 10 | React.useEffect(() => { 11 | const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); 12 | const onChange = () => { 13 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 14 | }; 15 | mql.addEventListener("change", onChange); 16 | setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); 17 | return () => mql.removeEventListener("change", onChange); 18 | }, []); 19 | 20 | return !!isMobile; 21 | } 22 | -------------------------------------------------------------------------------- /bts.jsonc: -------------------------------------------------------------------------------- 1 | // Better-T-Stack configuration file 2 | // safe to delete 3 | 4 | { 5 | "$schema": "https://r2.better-t-stack.dev/schema.json", 6 | "version": "2.49.1", 7 | "createdAt": "2025-09-30T15:25:11.154Z", 8 | "database": "postgres", 9 | "orm": "drizzle", 10 | "backend": "hono", 11 | "runtime": "workers", 12 | "frontend": [ 13 | "tanstack-start", 14 | "native-nativewind" 15 | ], 16 | "addons": [ 17 | "husky", 18 | "ruler", 19 | "turborepo", 20 | "ultracite" 21 | ], 22 | "examples": [], 23 | "auth": "better-auth", 24 | "packageManager": "pnpm", 25 | "dbSetup": "neon", 26 | "api": "orpc", 27 | "webDeploy": "wrangler", 28 | "serverDeploy": "wrangler" 29 | } -------------------------------------------------------------------------------- /apps/server/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ship-fullstack-server", 3 | "main": "src/index.ts", 4 | "compatibility_date": "2025-06-15", 5 | "compatibility_flags": ["nodejs_compat"], 6 | "vars": { 7 | "NODE_ENV": "production", 8 | "CORS_ORIGIN": "https://ship-fullstack.ship-fullstack.workers.dev", 9 | "BETTER_AUTH_URL": "https://ship-fullstack-server.ship-fullstack.workers.dev", 10 | // Add non-sensitive public IDs here: 11 | "GITHUB_CLIENT_ID": "Ov23lisJ5FpBoklrFAVk" 12 | // "GOOGLE_CLIENT_ID": "your_google_client_id" 13 | } 14 | // For sensitive data, use: 15 | // pnpm run secrets:setup 16 | // or: wrangler secret put SECRET_NAME 17 | // Don't add secrets to "vars" - they're visible in the dashboard! 18 | } 19 | -------------------------------------------------------------------------------- /apps/native/components/header-button.tsx: -------------------------------------------------------------------------------- 1 | import FontAwesome from "@expo/vector-icons/FontAwesome"; 2 | import { forwardRef } from "react"; 3 | import { Pressable } from "react-native"; 4 | 5 | export const HeaderButton = forwardRef< 6 | typeof Pressable, 7 | { onPress?: () => void } 8 | >(({ onPress }, _) => ( 9 | 13 | {({ pressed }) => ( 14 | 22 | )} 23 | 24 | )); 25 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as LabelPrimitive from "@radix-ui/react-label"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Label({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | export { Label }; 23 | -------------------------------------------------------------------------------- /apps/server/src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import type { Context as HonoContext } from "hono"; 2 | import { auth } from "./auth"; 3 | import { resolveTenantContext } from "./tenant"; 4 | 5 | export type CreateContextOptions = { 6 | context: HonoContext; 7 | }; 8 | 9 | export async function createContext({ context }: CreateContextOptions) { 10 | const session = await auth.api.getSession({ 11 | headers: context.req.raw.headers, 12 | }); 13 | const tenantContext = await resolveTenantContext({ 14 | request: context.req.raw, 15 | sessionUserId: session?.user?.id, 16 | }); 17 | 18 | return { 19 | session, 20 | ...tenantContext, 21 | }; 22 | } 23 | 24 | export type Context = Awaited>; 25 | -------------------------------------------------------------------------------- /apps/server/src/utils/tenant.ts: -------------------------------------------------------------------------------- 1 | const TENANT_ROUTE_SEGMENTS = new Set(["api", "rpc"]); 2 | 3 | export const stripTenantPrefixFromRequest = (request: Request): Request => { 4 | const url = new URL(request.url); 5 | const segments = url.pathname.split("/").filter(Boolean); 6 | 7 | if (segments.length < 2) { 8 | return request; 9 | } 10 | 11 | const [firstSegment, secondSegment] = segments; 12 | 13 | if (TENANT_ROUTE_SEGMENTS.has(firstSegment)) { 14 | return request; 15 | } 16 | 17 | if (!TENANT_ROUTE_SEGMENTS.has(secondSegment)) { 18 | return request; 19 | } 20 | 21 | const updatedUrl = new URL(url.toString()); 22 | updatedUrl.pathname = `/${segments.slice(1).join("/")}`; 23 | 24 | return new Request(updatedUrl.toString(), request); 25 | }; 26 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "target": "ES2022", 5 | "jsx": "react-jsx", 6 | "module": "ESNext", 7 | "lib": ["ES2022", "DOM", "DOM.Iterable"], 8 | "types": ["vite/client"], 9 | "allowJs": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "verbatimModuleSyntax": true, 15 | "noEmit": true, 16 | 17 | /* Linting */ 18 | "skipLibCheck": true, 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": ["./src/*"] 27 | } 28 | }, 29 | "references": [ 30 | { 31 | "path": "../server" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/separator.tsx: -------------------------------------------------------------------------------- 1 | import * as SeparatorPrimitive from "@radix-ui/react-separator"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Separator({ 7 | className, 8 | orientation = "horizontal", 9 | decorative = true, 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 23 | ); 24 | } 25 | 26 | export { Separator }; 27 | -------------------------------------------------------------------------------- /apps/server/.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | /build 4 | /out/ 5 | 6 | # dev 7 | .yarn/ 8 | !.yarn/patches 9 | !.yarn/plugins 10 | !.yarn/releases 11 | !.yarn/versions 12 | .vscode/* 13 | !.vscode/launch.json 14 | !.vscode/*.code-snippets 15 | .idea/workspace.xml 16 | .idea/usage.statistics.xml 17 | .idea/shelf 18 | .wrangler 19 | .alchemy 20 | /.next/ 21 | .vercel 22 | prisma/generated/ 23 | 24 | 25 | # deps 26 | node_modules/ 27 | /node_modules 28 | /.pnp 29 | .pnp.* 30 | 31 | # env 32 | .env* 33 | .env.production 34 | !.env.example 35 | .dev.vars 36 | .prod.vars 37 | 38 | # logs 39 | logs/ 40 | *.log 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* 44 | pnpm-debug.log* 45 | lerna-debug.log* 46 | 47 | # misc 48 | .DS_Store 49 | *.pem 50 | 51 | # local db 52 | *.db* 53 | 54 | # typescript 55 | *.tsbuildinfo 56 | next-env.d.ts 57 | -------------------------------------------------------------------------------- /apps/web/messages/zh.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/inlang-message-format", 3 | "example_message": "你好 {username}", 4 | "auth": { 5 | "login": { 6 | "sign_in": "登录", 7 | "description": "在下面输入您的电子邮件以登录您的帐户", 8 | "email": "邮件", 9 | "password": "密码", 10 | "forgot_password": "忘记密码?", 11 | "or_continue_with": "社交账号登录", 12 | "dont_have_an_account": "没有账号?" 13 | }, 14 | "sign_up": { 15 | "sign_up": "注册", 16 | "description": "输入您的信息以创建帐户", 17 | "register": "注册", 18 | "already_have_an_account": "已有账号?", 19 | "sign_in_with": "登录", 20 | "clicking_continue": "点击继续,表示您同意我们的", 21 | "terms_of_service": "服务条款", 22 | "privacy_policy": "隐私政策", 23 | "and": "和" 24 | } 25 | }, 26 | "language": { 27 | "switcher": "切换语言" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/src/components/sign-out-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmDialog } from "@/components/confirm-dialog"; 2 | import { useSignOut } from "@/hooks/use-sign-out"; 3 | 4 | interface SignOutDialogProps { 5 | open: boolean; 6 | onOpenChange: (open: boolean) => void; 7 | } 8 | 9 | export function SignOutDialog({ open, onOpenChange }: SignOutDialogProps) { 10 | const { signOut } = useSignOut(); 11 | 12 | const handleSignOut = () => { 13 | signOut(); 14 | }; 15 | 16 | return ( 17 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /apps/web/src/routes/(auth)/auth/route.tsx: -------------------------------------------------------------------------------- 1 | import { IconArrowLeft } from "@tabler/icons-react"; 2 | import { 3 | createFileRoute, 4 | Link, 5 | Outlet, 6 | useLocation, 7 | } from "@tanstack/react-router"; 8 | import { Button } from "@/components/ui/button"; 9 | 10 | export const Route = createFileRoute("/(auth)/auth")({ 11 | component: RouteComponent, 12 | }); 13 | 14 | function RouteComponent() { 15 | // hide back button on callback page 16 | const showBackButton = useLocation().pathname !== "/auth/callback"; 17 | 18 | return ( 19 |
20 | {/* back button */} 21 | {showBackButton && ( 22 | 23 | 26 | 27 | )} 28 | 29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /apps/server/src/lib/orpc.ts: -------------------------------------------------------------------------------- 1 | import { ORPCError, os } from "@orpc/server"; 2 | import type { Context } from "./context"; 3 | 4 | export const o = os.$context(); 5 | 6 | export const publicProcedure = o; 7 | 8 | const requireAuth = o.middleware(({ context, next }) => { 9 | if (!context.session?.user) { 10 | throw new ORPCError("UNAUTHORIZED"); 11 | } 12 | return next(); 13 | }); 14 | 15 | const requireTenantAccess = o.middleware(({ context, next }) => { 16 | if (!context.tenant) { 17 | throw new ORPCError("BAD_REQUEST", { message: "Tenant is required" }); 18 | } 19 | 20 | if (!context.tenantMembership) { 21 | throw new ORPCError("FORBIDDEN"); 22 | } 23 | 24 | return next(); 25 | }); 26 | 27 | export const protectedProcedure = publicProcedure.use(requireAuth); 28 | export const tenantProcedure = protectedProcedure.use(requireTenantAccess); 29 | -------------------------------------------------------------------------------- /apps/web/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.* 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/plugins 8 | !.yarn/releases 9 | !.yarn/versions 10 | 11 | # Testing 12 | /coverage 13 | 14 | # Build outputs 15 | /.next/ 16 | /out/ 17 | /build/ 18 | /dist/ 19 | .vinxi 20 | .output 21 | .react-router/ 22 | .tanstack/ 23 | .nitro/ 24 | 25 | # Deployment 26 | .vercel 27 | .netlify 28 | .wrangler 29 | .alchemy 30 | 31 | # Environment & local files 32 | .env* 33 | !.env.example 34 | !.env.development.example 35 | !.env.production.example 36 | .DS_Store 37 | *.pem 38 | *.local 39 | 40 | # Logs 41 | npm-debug.log* 42 | yarn-debug.log* 43 | yarn-error.log* 44 | .pnpm-debug.log* 45 | *.log* 46 | 47 | # TypeScript 48 | *.tsbuildinfo 49 | next-env.d.ts 50 | 51 | # IDE 52 | .vscode/* 53 | !.vscode/extensions.json 54 | .idea 55 | 56 | # Other 57 | dev-dist 58 | 59 | .wrangler 60 | .dev.vars* 61 | 62 | .open-next 63 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "ui": "tui", 4 | "tasks": { 5 | "build": { 6 | "dependsOn": ["^build"], 7 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 8 | "outputs": ["dist/**"] 9 | }, 10 | "lint": { 11 | "dependsOn": ["^lint"] 12 | }, 13 | "check-types": { 14 | "dependsOn": ["^check-types"] 15 | }, 16 | "dev": { 17 | "cache": false, 18 | "persistent": true 19 | }, 20 | "db:push": { 21 | "cache": false, 22 | "persistent": true 23 | }, 24 | "db:studio": { 25 | "cache": false, 26 | "persistent": true 27 | }, 28 | "db:migrate": { 29 | "cache": false, 30 | "persistent": true 31 | }, 32 | "db:generate": { 33 | "cache": false, 34 | "persistent": true 35 | }, 36 | "deploy": { 37 | "dependsOn": ["build"], 38 | "cache": false 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | function Collapsible({ 4 | ...props 5 | }: React.ComponentProps) { 6 | return 7 | } 8 | 9 | function CollapsibleTrigger({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return ( 13 | 17 | ) 18 | } 19 | 20 | function CollapsibleContent({ 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 28 | ) 29 | } 30 | 31 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .turbo 3 | .alchemy 4 | .env 5 | 6 | # START Ruler Generated Files 7 | /.codex/config.json 8 | /.codex/config.json.bak 9 | /.codex/config.toml 10 | /.codex/config.toml.bak 11 | /.cursor/mcp.json 12 | /.cursor/mcp.json.bak 13 | /.cursor/rules/ruler_cursor_instructions.mdc 14 | /.cursor/rules/ruler_cursor_instructions.mdc.bak 15 | /.gemini/settings.json 16 | /.gemini/settings.json.bak 17 | /.kilocode/mcp.json 18 | /.kilocode/mcp.json.bak 19 | /.kilocode/rules/ruler_kilocode_instructions.md 20 | /.kilocode/rules/ruler_kilocode_instructions.md.bak 21 | /.mcp.json 22 | /.mcp.json.bak 23 | /.vscode/mcp.json 24 | /.vscode/mcp.json.bak 25 | /.windsurf/mcp_config.json 26 | /.windsurf/mcp_config.json.bak 27 | /.windsurf/rules/ruler_windsurf_instructions.md 28 | /.windsurf/rules/ruler_windsurf_instructions.md.bak 29 | /.zed/settings.json 30 | /.zed/settings.json.bak 31 | /AGENTS.md 32 | /AGENTS.md.bak 33 | /CLAUDE.md 34 | /CLAUDE.md.bak 35 | # END Ruler Generated Files 36 | -------------------------------------------------------------------------------- /apps/web/src/components/site-header.tsx: -------------------------------------------------------------------------------- 1 | import { Separator } from "@/components/ui/separator"; 2 | import { SidebarTrigger } from "@/components/ui/sidebar"; 3 | import { ThemeSwitch } from "./theme-switch"; 4 | 5 | export function SiteHeader() { 6 | return ( 7 |
8 |
9 | 10 | 14 |

Documents

15 | 16 |
17 | {/* theme switch */} 18 | 19 |
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/types.ts: -------------------------------------------------------------------------------- 1 | import type { LinkProps } from "@tanstack/react-router"; 2 | 3 | type User = { 4 | name: string; 5 | email: string; 6 | avatar: string; 7 | }; 8 | 9 | type Team = { 10 | name: string; 11 | logo: React.ElementType; 12 | plan: string; 13 | }; 14 | 15 | type BaseNavItem = { 16 | title: string; 17 | badge?: string; 18 | icon?: React.ElementType; 19 | }; 20 | 21 | type NavLink = BaseNavItem & { 22 | url: LinkProps["to"] | (string & {}); 23 | items?: never; 24 | }; 25 | 26 | type NavCollapsible = BaseNavItem & { 27 | items: (BaseNavItem & { url: LinkProps["to"] | (string & {}) })[]; 28 | url?: never; 29 | }; 30 | 31 | type NavItem = NavCollapsible | NavLink; 32 | 33 | type NavGroup = { 34 | title: string; 35 | items: NavItem[]; 36 | }; 37 | 38 | type SidebarData = { 39 | user: User; 40 | teams: Team[]; 41 | navGroups: NavGroup[]; 42 | }; 43 | 44 | export type { SidebarData, NavGroup, NavItem, NavCollapsible, NavLink }; 45 | -------------------------------------------------------------------------------- /apps/web/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import LanguageSwitcher from "./language-switcher"; 3 | import { ThemeSwitch } from "./theme-switch"; 4 | import UserMenu from "./user-menu"; 5 | 6 | export default function Header() { 7 | const links = [{ to: "/", label: "Home" }] as const; 8 | 9 | return ( 10 |
11 |
12 | 19 | 20 |
21 | {/* theme switch */} 22 | 23 | {/* language switcher */} 24 | 25 | {/* user menu */} 26 | 27 |
28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /apps/web/src/utils/seo.ts: -------------------------------------------------------------------------------- 1 | export const seo = ({ 2 | title, 3 | description, 4 | keywords, 5 | image, 6 | }: { 7 | title: string; 8 | description?: string; 9 | image?: string; 10 | keywords?: string; 11 | }) => { 12 | const tags = [ 13 | { title }, 14 | { name: "description", content: description }, 15 | { name: "keywords", content: keywords }, 16 | { name: "twitter:title", content: title }, 17 | { name: "twitter:description", content: description }, 18 | { name: "twitter:creator", content: "@ShipFullStack" }, 19 | { name: "twitter:site", content: "@ShipFullStack" }, 20 | { name: "og:type", content: "website" }, 21 | { name: "og:title", content: title }, 22 | { name: "og:description", content: description }, 23 | ...(image 24 | ? [ 25 | { name: "twitter:image", content: image }, 26 | { name: "twitter:card", content: "summary_large_image" }, 27 | { name: "og:image", content: image }, 28 | ] 29 | : []), 30 | ]; 31 | 32 | return tags; 33 | }; 34 | -------------------------------------------------------------------------------- /.ruler/ruler.toml: -------------------------------------------------------------------------------- 1 | # Ruler Configuration File 2 | # See https://okigu.com/ruler for documentation. 3 | 4 | # Default agents to run when --agents is not specified 5 | default_agents = ["copilot", "claude", "codex", "cursor", "windsurf", "kilocode", "zed", "gemini-cli"] 6 | 7 | # --- Global MCP Server Configuration --- 8 | [mcp] 9 | # Enable/disable MCP propagation globally (default: true) 10 | enabled = true 11 | # Global merge strategy: 'merge' or 'overwrite' (default: 'merge') 12 | merge_strategy = "merge" 13 | 14 | # --- MCP Server Definitions --- 15 | [mcp_servers.context7] 16 | command = "npx" 17 | args = ["-y", "@upstash/context7-mcp"] 18 | 19 | 20 | 21 | 22 | 23 | 24 | [mcp_servers.neon] 25 | command = "npx" 26 | args = ["-y", "mcp-remote@latest", "https://mcp.neon.tech/mcp"] 27 | 28 | 29 | [mcp_servers.better-auth] 30 | url = "https://mcp.chonkie.ai/better-auth/better-auth-builder/mcp" 31 | 32 | 33 | # --- Global .gitignore Configuration --- 34 | [gitignore] 35 | # Enable/disable automatic .gitignore updates (default: true) 36 | enabled = true -------------------------------------------------------------------------------- /apps/web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Input({ className, type, ...props }: React.ComponentProps<"input">) { 6 | return ( 7 | 18 | ); 19 | } 20 | 21 | export { Input }; 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.defaultFormatter": "esbenp.prettier-vscode", 3 | "[javascript]": { 4 | "editor.defaultFormatter": "biomejs.biome" 5 | }, 6 | "[typescript]": { 7 | "editor.defaultFormatter": "biomejs.biome" 8 | }, 9 | "[javascriptreact]": { 10 | "editor.defaultFormatter": "biomejs.biome" 11 | }, 12 | "[typescriptreact]": { 13 | "editor.defaultFormatter": "biomejs.biome" 14 | }, 15 | "[json]": { 16 | "editor.defaultFormatter": "biomejs.biome" 17 | }, 18 | "[jsonc]": { 19 | "editor.defaultFormatter": "biomejs.biome" 20 | }, 21 | "[css]": { 22 | "editor.defaultFormatter": "biomejs.biome" 23 | }, 24 | "[graphql]": { 25 | "editor.defaultFormatter": "biomejs.biome" 26 | }, 27 | "typescript.tsdk": "node_modules/typescript/lib", 28 | "editor.formatOnSave": true, 29 | "editor.formatOnPaste": true, 30 | "emmet.showExpandedAbbreviation": "never", 31 | "editor.codeActionsOnSave": { 32 | "source.fixAll.biome": "explicit", 33 | "source.organizeImports.biome": "explicit" 34 | }, 35 | "oxc.enable": true 36 | } -------------------------------------------------------------------------------- /apps/web/src/router.tsx: -------------------------------------------------------------------------------- 1 | import { createRouter as createTanStackRouter } from "@tanstack/react-router"; 2 | import Loader from "./components/loader"; 3 | import "./index.css"; 4 | import { deLocalizeUrl, localizeUrl } from "./paraglide/runtime.js"; 5 | import { Providers } from "./providers"; 6 | import { routeTree } from "./routeTree.gen"; 7 | import { orpc, queryClient } from "./utils/orpc"; 8 | 9 | export const getRouter = () => { 10 | const router = createTanStackRouter({ 11 | routeTree, 12 | scrollRestoration: true, 13 | defaultPreloadStaleTime: 0, 14 | rewrite: { 15 | input: ({ url }) => deLocalizeUrl(url), 16 | output: ({ url }) => localizeUrl(url), 17 | }, 18 | context: { orpc, queryClient }, 19 | defaultPendingComponent: () => , 20 | defaultNotFoundComponent: () =>
Not Found
, 21 | Wrap: ({ children }) => {children}, 22 | }); 23 | return router; 24 | }; 25 | 26 | declare module "@tanstack/react-router" { 27 | interface Register { 28 | router: ReturnType; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/web/messages/en.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://inlang.com/schema/inlang-message-format", 3 | "example_message": "Hello {username}", 4 | "auth": { 5 | "login": { 6 | "sign_in": "Sign In", 7 | "description": "Enter your email below to login to your account", 8 | "email": "Email", 9 | "password": "Password", 10 | "forgot_password": "Forgot your password?", 11 | "or_continue_with": "Or continue with", 12 | "dont_have_an_account": "Don't have an account?" 13 | }, 14 | "sign_up": { 15 | "sign_up": "Sign Up", 16 | "description": "Enter your information to create an account", 17 | "create_account": "Register", 18 | "register": "Register", 19 | "already_have_an_account": "Already have an account?", 20 | "sign_in_with": "Sign in with", 21 | "clicking_continue": "By clicking continue, you agree to our", 22 | "terms_of_service": "Terms of Service", 23 | "privacy_policy": "Privacy Policy", 24 | "and": "and" 25 | } 26 | }, 27 | "language": { 28 | "switcher": "Switch Language" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /apps/native/app/(drawer)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Ionicons, MaterialIcons } from "@expo/vector-icons"; 2 | import { Link } from "expo-router"; 3 | import { Drawer } from "expo-router/drawer"; 4 | 5 | import { HeaderButton } from "@/components/header-button"; 6 | 7 | const DrawerLayout = () => ( 8 | 9 | ( 15 | 16 | ), 17 | }} 18 | /> 19 | ( 25 | 26 | ), 27 | headerRight: () => ( 28 | 29 | 30 | 31 | ), 32 | }} 33 | /> 34 | 35 | ); 36 | 37 | export default DrawerLayout; 38 | -------------------------------------------------------------------------------- /apps/web/src/components/nav-secondary.tsx: -------------------------------------------------------------------------------- 1 | import type { Icon } from "@tabler/icons-react"; 2 | import type * as React from "react"; 3 | 4 | import { 5 | SidebarGroup, 6 | SidebarGroupContent, 7 | SidebarMenu, 8 | SidebarMenuButton, 9 | SidebarMenuItem, 10 | } from "@/components/ui/sidebar"; 11 | 12 | export function NavSecondary({ 13 | items, 14 | ...props 15 | }: { 16 | items: { 17 | title: string; 18 | url: string; 19 | icon: Icon; 20 | }[]; 21 | } & React.ComponentPropsWithoutRef) { 22 | return ( 23 | 24 | 25 | 26 | {items.map((item) => ( 27 | 28 | 29 | 30 | 31 | {item.title} 32 | 33 | 34 | 35 | ))} 36 | 37 | 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /apps/native/app/+not-found.tsx: -------------------------------------------------------------------------------- 1 | import { Link, Stack } from "expo-router"; 2 | import { Text, View } from "react-native"; 3 | import { Container } from "@/components/container"; 4 | 5 | export default function NotFoundScreen() { 6 | return ( 7 | <> 8 | 9 | 10 | 11 | 12 | 🤔 13 | 14 | Page Not Found 15 | 16 | 17 | Sorry, the page you're looking for doesn't exist. 18 | 19 | 20 | 21 | Go to Home 22 | 23 | 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /apps/native/utils/orpc.ts: -------------------------------------------------------------------------------- 1 | import { createORPCClient } from "@orpc/client"; 2 | import { RPCLink } from "@orpc/client/fetch"; 3 | import { createTanstackQueryUtils } from "@orpc/tanstack-query"; 4 | import { QueryCache, QueryClient } from "@tanstack/react-query"; 5 | import { authClient } from "@/lib/auth-client"; 6 | import type { AppRouterClient } from "../../server/src/routers"; 7 | 8 | export const queryClient = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: 1000 * 60, 12 | }, 13 | }, 14 | queryCache: new QueryCache({ 15 | onError: (error) => { 16 | console.log(error); 17 | }, 18 | }), 19 | }); 20 | 21 | export const link = new RPCLink({ 22 | url: `${process.env.EXPO_PUBLIC_SERVER_URL}/rpc`, 23 | headers() { 24 | const headers = new Map(); 25 | const cookies = authClient.getCookie(); 26 | if (cookies) { 27 | headers.set("Cookie", cookies); 28 | } 29 | return Object.fromEntries(headers); 30 | }, 31 | }); 32 | 33 | export const client: AppRouterClient = createORPCClient(link); 34 | 35 | export const orpc = createTanstackQueryUtils(client); 36 | -------------------------------------------------------------------------------- /apps/native/metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require("expo/metro-config"); 3 | const { FileStore } = require("metro-cache"); 4 | const { withNativeWind } = require("nativewind/metro"); 5 | const path = require("node:path"); 6 | 7 | const config = withTurborepoManagedCache( 8 | withNativeWind(getDefaultConfig(__dirname), { 9 | input: "./global.css", 10 | configPath: "./tailwind.config.js", 11 | }) 12 | ); 13 | 14 | config.resolver.unstable_enablePackageExports = true; 15 | 16 | module.exports = config; 17 | 18 | /** 19 | * Move the Metro cache to the `.cache/metro` folder. 20 | * If you have any environment variables, you can configure Turborepo to invalidate it when needed. 21 | * 22 | * @see https://turbo.build/repo/docs/reference/configuration#env 23 | * @param {import('expo/metro-config').MetroConfig} config 24 | * @returns {import('expo/metro-config').MetroConfig} 25 | */ 26 | function withTurborepoManagedCache(config) { 27 | config.cacheStores = [ 28 | new FileStore({ root: path.join(__dirname, ".cache/metro") }), 29 | ]; 30 | return config; 31 | } 32 | -------------------------------------------------------------------------------- /apps/server/src/middlewares/cors.ts: -------------------------------------------------------------------------------- 1 | import type { MiddlewareHandler } from "hono"; 2 | import { cors } from "hono/cors"; 3 | 4 | // CORS middleware for auth endpoints 5 | export const authCorsMiddleware: MiddlewareHandler = (c, next) => { 6 | const env = c.env as Record; 7 | const corsMiddleware = cors({ 8 | origin: env.CORS_ORIGIN || "*", 9 | allowHeaders: ["Content-Type", "Authorization", "Cookie"], 10 | allowMethods: ["POST", "GET", "OPTIONS"], 11 | exposeHeaders: ["Content-Length", "Set-Cookie"], 12 | maxAge: 600, 13 | credentials: true, 14 | }); 15 | return corsMiddleware(c, next); 16 | }; 17 | 18 | // CORS middleware for API and RPC endpoints 19 | export const apiCorsMiddleware: MiddlewareHandler = (c, next) => { 20 | const env = c.env as Record; 21 | const corsMiddleware = cors({ 22 | origin: env.CORS_ORIGIN || "*", 23 | allowMethods: ["GET", "POST", "OPTIONS"], 24 | allowHeaders: [ 25 | "Content-Type", 26 | "Authorization", 27 | "x-tenant-id", 28 | "x-tenant-slug", 29 | ], 30 | credentials: true, 31 | }); 32 | return corsMiddleware(c, next); 33 | }; 34 | -------------------------------------------------------------------------------- /apps/web/src/providers.tsx: -------------------------------------------------------------------------------- 1 | import { AuthQueryProvider } from "@daveyplate/better-auth-tanstack"; 2 | import { AuthUIProviderTanstack } from "@daveyplate/better-auth-ui/tanstack"; 3 | import { QueryClientProvider } from "@tanstack/react-query"; 4 | import { Link } from "@tanstack/react-router"; 5 | import type { ReactNode } from "react"; 6 | import { authClient } from "@/lib/auth/auth-client"; 7 | import { queryClient } from "@/utils/orpc"; 8 | import { getRouter } from "./router"; 9 | 10 | export function Providers({ children }: { children: ReactNode }) { 11 | const router = getRouter(); 12 | return ( 13 | 14 | 15 | } 18 | navigate={(href) => router.navigate({ href })} 19 | replace={(href) => router.navigate({ href, replace: true })} 20 | social={{ providers: ["github", "google"] }} 21 | > 22 | {children} 23 | 24 | 25 | 26 | ); 27 | } 28 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CircleCheckIcon, 3 | InfoIcon, 4 | Loader2Icon, 5 | OctagonXIcon, 6 | TriangleAlertIcon, 7 | } from "lucide-react"; 8 | import { useTheme } from "next-themes"; 9 | import { Toaster as Sonner, type ToasterProps } from "sonner"; 10 | 11 | const Toaster = ({ ...props }: ToasterProps) => { 12 | const { theme = "system" } = useTheme(); 13 | 14 | return ( 15 | , 19 | info: , 20 | warning: , 21 | error: , 22 | loading: , 23 | }} 24 | style={ 25 | { 26 | "--normal-bg": "var(--popover)", 27 | "--normal-text": "var(--popover-foreground)", 28 | "--normal-border": "var(--border)", 29 | "--border-radius": "var(--radius)", 30 | } as React.CSSProperties 31 | } 32 | theme={theme as ToasterProps["theme"]} 33 | {...props} 34 | /> 35 | ); 36 | }; 37 | 38 | export { Toaster }; 39 | -------------------------------------------------------------------------------- /apps/web/src/utils/orpc.ts: -------------------------------------------------------------------------------- 1 | import { createORPCClient } from "@orpc/client"; 2 | import { RPCLink } from "@orpc/client/fetch"; 3 | import { createTanstackQueryUtils } from "@orpc/tanstack-query"; 4 | import { QueryCache, QueryClient } from "@tanstack/react-query"; 5 | import { toast } from "sonner"; 6 | import type { AppRouterClient } from "../../../server/src/routers/index"; 7 | 8 | export const queryClient = new QueryClient({ 9 | defaultOptions: { 10 | queries: { 11 | staleTime: 1000 * 60, 12 | }, 13 | }, 14 | queryCache: new QueryCache({ 15 | onError: (error) => { 16 | toast.error(`Error: ${error.message}`, { 17 | action: { 18 | label: "retry", 19 | onClick: () => { 20 | queryClient.invalidateQueries(); 21 | }, 22 | }, 23 | }); 24 | }, 25 | }), 26 | }); 27 | 28 | export const link = new RPCLink({ 29 | url: `${import.meta.env.VITE_SERVER_URL}/rpc`, 30 | fetch(url, options) { 31 | return fetch(url, { 32 | ...options, 33 | credentials: "include", 34 | }); 35 | }, 36 | }); 37 | 38 | export const client: AppRouterClient = createORPCClient(link); 39 | 40 | export const orpc = createTanstackQueryUtils(client); 41 | -------------------------------------------------------------------------------- /apps/server/src/db/schema/tenant.ts: -------------------------------------------------------------------------------- 1 | import { 2 | pgEnum, 3 | pgTable, 4 | text, 5 | timestamp, 6 | uniqueIndex, 7 | } from "drizzle-orm/pg-core"; 8 | import { user } from "./auth"; 9 | 10 | export const tenantRole = pgEnum("tenant_role", ["owner", "admin", "member"]); 11 | 12 | export const tenant = pgTable("tenant", { 13 | id: text("id").primaryKey(), 14 | slug: text("slug").notNull().unique(), 15 | name: text("name").notNull(), 16 | createdAt: timestamp("created_at").notNull(), 17 | updatedAt: timestamp("updated_at").notNull(), 18 | }); 19 | 20 | export const tenantMembership = pgTable( 21 | "tenant_membership", 22 | { 23 | id: text("id").primaryKey(), 24 | tenantId: text("tenant_id") 25 | .notNull() 26 | .references(() => tenant.id, { onDelete: "cascade" }), 27 | userId: text("user_id") 28 | .notNull() 29 | .references(() => user.id, { onDelete: "cascade" }), 30 | role: tenantRole("role").notNull().default("member"), 31 | createdAt: timestamp("created_at").notNull(), 32 | updatedAt: timestamp("updated_at").notNull(), 33 | }, 34 | (table) => ({ 35 | tenantMembershipTenantUserIdx: uniqueIndex( 36 | "tenant_membership_tenant_id_user_id_idx" 37 | ).on(table.tenantId, table.userId), 38 | }) 39 | ); 40 | -------------------------------------------------------------------------------- /apps/web/src/lib/cookies.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cookie utility functions using manual document.cookie approach 3 | * Replaces js-cookie dependency for better consistency 4 | */ 5 | 6 | const DEFAULT_MAX_AGE = 60 * 60 * 24 * 7; // 7 days 7 | 8 | /** 9 | * Get a cookie value by name 10 | */ 11 | export function getCookie(name: string): string | undefined { 12 | if (typeof document === "undefined") { 13 | return undefined; 14 | } 15 | 16 | const value = `; ${document.cookie}`; 17 | const parts = value.split(`; ${name}=`); 18 | if (parts.length === 2) { 19 | const cookieValue = parts.pop()?.split(";").shift(); 20 | return cookieValue; 21 | } 22 | return undefined; 23 | } 24 | 25 | /** 26 | * Set a cookie with name, value, and optional max age 27 | */ 28 | export function setCookie( 29 | name: string, 30 | value: string, 31 | maxAge: number = DEFAULT_MAX_AGE 32 | ): void { 33 | if (typeof document === "undefined") { 34 | return; 35 | } 36 | 37 | document.cookie = `${name}=${value}; path=/; max-age=${maxAge}`; 38 | } 39 | 40 | /** 41 | * Remove a cookie by setting its max age to 0 42 | */ 43 | export function removeCookie(name: string): void { 44 | if (typeof document === "undefined") { 45 | return; 46 | } 47 | 48 | document.cookie = `${name}=; path=/; max-age=0`; 49 | } 50 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/app-sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { useLayout } from "@/components/context/layout-provider"; 2 | import { 3 | Sidebar, 4 | SidebarContent, 5 | SidebarFooter, 6 | SidebarHeader, 7 | SidebarRail, 8 | } from "@/components/ui/sidebar"; 9 | // import { AppTitle } from './app-title' 10 | import { sidebarData } from "./data/sidebar-data"; 11 | import { NavGroup } from "./nav-group"; 12 | import { NavUser } from "./nav-user"; 13 | import { TeamSwitcher } from "./team-switcher"; 14 | 15 | export function AppSidebar() { 16 | const { collapsible, variant } = useLayout(); 17 | return ( 18 | 19 | 20 | 21 | 22 | {/* Replace with the following 23 | /* if you want to use the normal app title instead of TeamSwitch dropdown */} 24 | {/* */} 25 | 26 | 27 | {sidebarData.navGroups.map((props) => ( 28 | 29 | ))} 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/avatar.tsx: -------------------------------------------------------------------------------- 1 | import * as AvatarPrimitive from "@radix-ui/react-avatar"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Avatar({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 19 | ); 20 | } 21 | 22 | function AvatarImage({ 23 | className, 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 32 | ); 33 | } 34 | 35 | function AvatarFallback({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | export { Avatar, AvatarImage, AvatarFallback }; 52 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox"; 2 | import { CheckIcon } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Checkbox({ 8 | className, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 20 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | export { Checkbox }; 31 | -------------------------------------------------------------------------------- /apps/web/src/routes/(auth)/auth/callback.tsx: -------------------------------------------------------------------------------- 1 | import { createFileRoute, useNavigate } from "@tanstack/react-router"; 2 | import { useEffect } from "react"; 3 | import Loader from "@/components/loader"; 4 | import { authClient } from "@/lib/auth/auth-client"; 5 | 6 | export const Route = createFileRoute("/(auth)/auth/callback")({ 7 | component: RouteComponent, 8 | }); 9 | 10 | function RouteComponent() { 11 | const navigate = useNavigate(); 12 | 13 | useEffect(() => { 14 | const handleCallback = async () => { 15 | const maxAttempts = 10; 16 | const interval = 300; 17 | 18 | for (let attempt = 0; attempt < maxAttempts; attempt++) { 19 | const session = await authClient.getSession(); 20 | 21 | if (session.data?.user) { 22 | navigate({ 23 | to: "/dashboard", 24 | }); 25 | return; 26 | } 27 | 28 | if (attempt < maxAttempts - 1) { 29 | await new Promise((resolve) => setTimeout(resolve, interval)); 30 | } 31 | } 32 | navigate({ 33 | to: "/auth/$authView", 34 | params: { 35 | authView: "login", 36 | }, 37 | }); 38 | }; 39 | 40 | handleCallback(); 41 | }, []); 42 | 43 | return ( 44 |
45 | 46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /apps/web/src/components/context/search-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | import { CommandMenu } from "@/components/command-menu"; 3 | 4 | type SearchContextType = { 5 | open: boolean; 6 | setOpen: React.Dispatch>; 7 | }; 8 | 9 | const SearchContext = createContext(null); 10 | 11 | type SearchProviderProps = { 12 | children: React.ReactNode; 13 | }; 14 | 15 | export function SearchProvider({ children }: SearchProviderProps) { 16 | const [open, setOpen] = useState(false); 17 | 18 | useEffect(() => { 19 | const down = (e: KeyboardEvent) => { 20 | if (e.key === "k" && (e.metaKey || e.ctrlKey)) { 21 | e.preventDefault(); 22 | setOpen((open) => !open); 23 | } 24 | }; 25 | document.addEventListener("keydown", down); 26 | return () => document.removeEventListener("keydown", down); 27 | }, []); 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | 34 | ); 35 | } 36 | 37 | // eslint-disable-next-line react-refresh/only-export-components 38 | export const useSearch = () => { 39 | const searchContext = useContext(SearchContext); 40 | 41 | if (!searchContext) { 42 | throw new Error("useSearch has to be used within SearchProvider"); 43 | } 44 | 45 | return searchContext; 46 | }; 47 | -------------------------------------------------------------------------------- /apps/native/app/(drawer)/(tabs)/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { Tabs } from "expo-router"; 2 | import { TabBarIcon } from "@/components/tabbar-icon"; 3 | import { useColorScheme } from "@/lib/use-color-scheme"; 4 | 5 | export default function TabLayout() { 6 | const { isDarkColorScheme } = useColorScheme(); 7 | 8 | return ( 9 | 28 | , 33 | }} 34 | /> 35 | ( 40 | 41 | ), 42 | }} 43 | /> 44 | 45 | ); 46 | } 47 | -------------------------------------------------------------------------------- /apps/native/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "easyapp-fullstack-template", 4 | "slug": "easyapp-fullstack-template", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/images/icon.png", 8 | "scheme": "mybettertapp", 9 | "userInterfaceStyle": "automatic", 10 | "newArchEnabled": true, 11 | "ios": { 12 | "supportsTablet": true 13 | }, 14 | "android": { 15 | "adaptiveIcon": { 16 | "backgroundColor": "#E6F4FE", 17 | "foregroundImage": "./assets/images/android-icon-foreground.png", 18 | "backgroundImage": "./assets/images/android-icon-background.png", 19 | "monochromeImage": "./assets/images/android-icon-monochrome.png" 20 | }, 21 | "edgeToEdgeEnabled": true, 22 | "predictiveBackGestureEnabled": false, 23 | "package": "com.anonymous.mybettertapp" 24 | }, 25 | "web": { 26 | "output": "static", 27 | "favicon": "./assets/images/favicon.png" 28 | }, 29 | "plugins": [ 30 | "expo-router", 31 | [ 32 | "expo-splash-screen", 33 | { 34 | "image": "./assets/images/splash-icon.png", 35 | "imageWidth": 200, 36 | "resizeMode": "contain", 37 | "backgroundColor": "#ffffff", 38 | "dark": { 39 | "backgroundColor": "#000000" 40 | } 41 | } 42 | ] 43 | ], 44 | "experiments": { 45 | "typedRoutes": true, 46 | "reactCompiler": true 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ship-fullstack", 3 | "private": true, 4 | "type": "module", 5 | "workspaces": [ 6 | "apps/*", 7 | "packages/*" 8 | ], 9 | "scripts": { 10 | "check": "biome check --write .", 11 | "ruler:apply": "pnpm dlx @intellectronica/ruler@latest apply --local-only", 12 | "dev": "turbo dev", 13 | "build": "turbo build", 14 | "check-types": "turbo check-types", 15 | "dev:native": "turbo -F native dev", 16 | "dev:web": "turbo -F web dev", 17 | "dev:server": "turbo -F server dev", 18 | "db:push": "turbo -F server db:push", 19 | "db:studio": "turbo -F server db:studio", 20 | "db:generate": "turbo -F server db:generate", 21 | "db:migrate": "turbo -F server db:migrate", 22 | "deploy:web": "turbo -F web deploy", 23 | "deploy:server": "turbo -F server deploy", 24 | "deploy": "turbo -F web -F server deploy", 25 | "husky:install": "husky install", 26 | "commit": "git add . && npx cz && git push" 27 | }, 28 | "devDependencies": { 29 | "@biomejs/biome": "2.2.4", 30 | "cz-conventional-changelog": "^3.3.0", 31 | "husky": "^9.1.7", 32 | "lint-staged": "^16.1.2", 33 | "turbo": "^2.5.4", 34 | "ultracite": "5.4.5" 35 | }, 36 | "lint-staged": { 37 | "*.{js,jsx,ts,tsx,json,jsonc,css,scss,md,mdx}": [ 38 | "pnpm dlx ultracite fix" 39 | ] 40 | }, 41 | "packageManager": "pnpm@10.17.1", 42 | "config": { 43 | "commitizen": { 44 | "path": "cz-conventional-changelog", 45 | "maxHeaderWidth": 0, 46 | "maxLineWidth": 0 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /apps/native/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 222.2 84% 4.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 222.2 84% 4.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 222.2 84% 4.9%; 13 | --primary: 221.2 83.2% 53.3%; 14 | --primary-foreground: 210 40% 98%; 15 | --secondary: 210 40% 96%; 16 | --secondary-foreground: 222.2 84% 4.9%; 17 | --muted: 210 40% 96%; 18 | --muted-foreground: 215.4 16.3% 40%; 19 | --accent: 210 40% 96%; 20 | --accent-foreground: 222.2 84% 4.9%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 210 40% 98%; 23 | --border: 214.3 31.8% 91.4%; 24 | --input: 214.3 31.8% 91.4%; 25 | --ring: 221.2 83.2% 53.3%; 26 | --radius: 8px; 27 | } 28 | 29 | .dark:root { 30 | --background: 222.2 84% 4.9%; 31 | --foreground: 210 40% 98%; 32 | --card: 222.2 84% 4.9%; 33 | --card-foreground: 210 40% 98%; 34 | --popover: 222.2 84% 4.9%; 35 | --popover-foreground: 210 40% 98%; 36 | --primary: 217.2 91.2% 59.8%; 37 | --primary-foreground: 222.2 84% 4.9%; 38 | --secondary: 217.2 32.6% 17.5%; 39 | --secondary-foreground: 210 40% 98%; 40 | --muted: 217.2 32.6% 17.5%; 41 | --muted-foreground: 215 20.2% 70%; 42 | --accent: 217.2 32.6% 17.5%; 43 | --accent-foreground: 210 40% 98%; 44 | --destructive: 0 72% 51%; 45 | --destructive-foreground: 210 40% 98%; 46 | --border: 217.2 32.6% 17.5%; 47 | --input: 217.2 32.6% 17.5%; 48 | --ring: 224.3 76.3% 94.1%; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "main": "src/index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "build": "tsdown", 7 | "check-types": "tsc -b", 8 | "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", 9 | "db:push": "drizzle-kit push", 10 | "db:studio": "drizzle-kit studio", 11 | "db:generate": "drizzle-kit generate", 12 | "db:migrate": "drizzle-kit migrate", 13 | "dev": "wrangler dev --port=15000", 14 | "start": "wrangler dev", 15 | "deploy": "wrangler deploy", 16 | "secrets:setup": "node scripts/setup-secrets.mjs", 17 | "sync:secrets": "node scripts/sync-wrangler-secrets.js --env-file=.env --only=DATABASE_URL,DATABASE_URL_POOLER,BETTER_AUTH_SECRET --worker=ship-fullstack-server", 18 | "cf-typegen": "wrangler types --env-interface CloudflareBindings", 19 | "generate-types": "wrangler types" 20 | }, 21 | "dependencies": { 22 | "@better-auth/expo": "^1.3.13", 23 | "@hono/node-server": "^1.14.4", 24 | "@neondatabase/serverless": "^1.0.1", 25 | "@orpc/client": "^1.9.0", 26 | "@orpc/openapi": "^1.9.0", 27 | "@orpc/server": "^1.9.0", 28 | "@orpc/zod": "^1.9.0", 29 | "better-auth": "^1.3.13", 30 | "dotenv": "^17.2.1", 31 | "drizzle-orm": "^0.44.2", 32 | "hono": "^4.8.2", 33 | "ws": "^8.18.3", 34 | "zod": "^4.0.2" 35 | }, 36 | "devDependencies": { 37 | "@types/node": "^22.13.11", 38 | "@types/ws": "^8.18.1", 39 | "drizzle-kit": "^0.31.2", 40 | "tsdown": "^0.15.1", 41 | "tsx": "^4.19.2", 42 | "typescript": "^5.8.2", 43 | "wrangler": "^4.42.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/web/src/routes/_authenticated/(dashboard)/dashboard.tsx: -------------------------------------------------------------------------------- 1 | // import { useQuery } from "@tanstack/react-query"; 2 | import { createFileRoute, redirect } from "@tanstack/react-router"; 3 | import { ChartAreaInteractive } from "@/components/chart-area-interactive"; 4 | import { DataTable } from "@/components/data-table"; 5 | import { SectionCards } from "@/components/section-cards"; 6 | import { SiteHeader } from "@/components/site-header"; 7 | import data from "@/configs/dashboard.json" with { type: "json" }; 8 | import { authClient } from "@/lib/auth/auth-client"; 9 | // import { orpc } from "@/utils/orpc"; 10 | 11 | export const Route = createFileRoute("/_authenticated/(dashboard)/dashboard")({ 12 | component: RouteComponent, 13 | beforeLoad: async () => { 14 | const session = await authClient.getSession(); 15 | if (!session.data) { 16 | redirect({ 17 | to: "/auth/$authView", 18 | params: { 19 | authView: "login", 20 | }, 21 | throw: true, 22 | }); 23 | } 24 | return { session }; 25 | }, 26 | }); 27 | 28 | function RouteComponent() { 29 | // const { session } = Route.useRouteContext(); 30 | 31 | // const privateData = useQuery(orpc.privateData.queryOptions()); 32 | 33 | return ( 34 | <> 35 | 36 |
37 |
38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 |
47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /apps/native/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "native", 3 | "version": "1.0.0", 4 | "main": "expo-router/entry", 5 | "scripts": { 6 | "dev": "expo start --clear", 7 | "android": "expo run:android", 8 | "ios": "expo run:ios", 9 | "prebuild": "expo prebuild", 10 | "web": "expo start --web" 11 | }, 12 | "dependencies": { 13 | "@expo/vector-icons": "^15.0.2", 14 | "@react-navigation/bottom-tabs": "^7.2.0", 15 | "@react-navigation/drawer": "^7.1.1", 16 | "@react-navigation/native": "^7.0.14", 17 | "@tanstack/react-form": "^1.0.5", 18 | "@tanstack/react-query": "^5.85.5", 19 | "expo": "^54.0.1", 20 | "expo-constants": "~18.0.8", 21 | "expo-crypto": "~15.0.6", 22 | "expo-linking": "~8.0.7", 23 | "expo-navigation-bar": "~5.0.8", 24 | "expo-router": "~6.0.0", 25 | "expo-secure-store": "~15.0.6", 26 | "expo-splash-screen": "~31.0.8", 27 | "expo-status-bar": "~3.0.7", 28 | "expo-system-ui": "~6.0.7", 29 | "expo-web-browser": "~15.0.6", 30 | "nativewind": "^4.1.23", 31 | "react": "19.1.0", 32 | "react-dom": "19.1.0", 33 | "react-native": "0.82.1", 34 | "react-native-gesture-handler": "~2.28.0", 35 | "react-native-reanimated": "~4.1.0", 36 | "react-native-safe-area-context": "~5.6.0", 37 | "react-native-screens": "~4.16.0", 38 | "react-native-web": "^0.21.0", 39 | "react-native-worklets": "^0.5.1", 40 | "@orpc/tanstack-query": "^1.9.0", 41 | "@orpc/client": "^1.9.0", 42 | "better-auth": "^1.3.13", 43 | "@better-auth/expo": "^1.3.13" 44 | }, 45 | "devDependencies": { 46 | "@babel/core": "^7.26.10", 47 | "@types/react": "~19.1.10", 48 | "tailwindcss": "^3.4.17", 49 | "typescript": "~5.8.2" 50 | }, 51 | "private": true 52 | } 53 | -------------------------------------------------------------------------------- /apps/web/src/components/layout/authenticated-layout.tsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "@tanstack/react-router"; 2 | import { LayoutProvider } from "@/components/context/layout-provider"; 3 | import { SearchProvider } from "@/components/context/search-provider"; 4 | import { AppSidebar } from "@/components/layout/app-sidebar"; 5 | import { SkipToMain } from "@/components/skip-to-main"; 6 | import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; 7 | 8 | import { getCookie } from "@/lib/cookies"; 9 | import { cn } from "@/lib/utils"; 10 | 11 | type AuthenticatedLayoutProps = { 12 | children?: React.ReactNode; 13 | }; 14 | 15 | export function AuthenticatedLayout({ children }: AuthenticatedLayoutProps) { 16 | const defaultOpen = getCookie("sidebar_state") !== "false"; 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 37 | {children ?? } 38 | 39 | 40 | 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/toggle.tsx: -------------------------------------------------------------------------------- 1 | import * as TogglePrimitive from "@radix-ui/react-toggle"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const toggleVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md font-medium text-sm outline-none transition-[color,box-shadow] hover:bg-muted hover:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground dark:aria-invalid:ring-destructive/40 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-transparent", 13 | outline: 14 | "border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground", 15 | }, 16 | size: { 17 | default: "h-9 min-w-9 px-2", 18 | sm: "h-8 min-w-8 px-1.5", 19 | lg: "h-10 min-w-10 px-2.5", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | size: "default", 25 | }, 26 | } 27 | ); 28 | 29 | function Toggle({ 30 | className, 31 | variant, 32 | size, 33 | ...props 34 | }: React.ComponentProps & 35 | VariantProps) { 36 | return ( 37 | 42 | ); 43 | } 44 | 45 | export { Toggle, toggleVariants }; 46 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { cva, type VariantProps } from "class-variance-authority"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | const badgeVariants = cva( 8 | "inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden whitespace-nowrap rounded-md border px-2 py-0.5 font-medium text-xs transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90", 14 | secondary: 15 | "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90", 16 | destructive: 17 | "border-transparent bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90", 18 | outline: 19 | "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground", 20 | }, 21 | }, 22 | defaultVariants: { 23 | variant: "default", 24 | }, 25 | } 26 | ); 27 | 28 | function Badge({ 29 | className, 30 | variant, 31 | asChild = false, 32 | ...props 33 | }: React.ComponentProps<"span"> & 34 | VariantProps & { asChild?: boolean }) { 35 | const Comp = asChild ? Slot : "span"; 36 | 37 | return ( 38 | 43 | ); 44 | } 45 | 46 | export { Badge, badgeVariants }; 47 | -------------------------------------------------------------------------------- /apps/server/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { env } from "cloudflare:workers"; 2 | import { expo } from "@better-auth/expo"; 3 | import { type BetterAuthOptions, betterAuth } from "better-auth"; 4 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 5 | import { db } from "../db"; 6 | import { account, session, user, verification } from "../db/schema/auth"; 7 | 8 | export const auth = betterAuth({ 9 | baseURL: env.BETTER_AUTH_URL || "", 10 | appName: "ShipFullStack", 11 | database: drizzleAdapter(db, { 12 | provider: "pg", 13 | 14 | schema: { 15 | user, 16 | session, 17 | account, 18 | verification, 19 | }, 20 | }), 21 | // trustedOrigins: [process.env.CORS_ORIGIN || "", "mybettertapp://", "exp://"], 22 | trustedOrigins: [env.CORS_ORIGIN || "", "mybettertapp://", "exp://"], 23 | emailAndPassword: { 24 | enabled: true, 25 | }, 26 | // https://www.better-auth.com/docs/concepts/oauth 27 | socialProviders: { 28 | github: { 29 | enabled: true, 30 | clientId: env.GITHUB_CLIENT_ID || "", 31 | clientSecret: env.GITHUB_CLIENT_SECRET || "", 32 | redirectURI: `${env.BETTER_AUTH_URL}/api/auth/callback/github`, // Server callback URL 33 | }, 34 | google: { 35 | enabled: true, 36 | clientId: env.GOOGLE_CLIENT_ID || "", 37 | clientSecret: env.GOOGLE_CLIENT_SECRET || "", 38 | redirectURI: `${env.BETTER_AUTH_URL}/api/auth/callback/google`, // Server callback URL 39 | }, 40 | }, 41 | advanced: { 42 | defaultCookieAttributes: { 43 | sameSite: "lax", 44 | secure: env.BETTER_AUTH_URL?.startsWith("https") ?? false, // Auto-enable in production 45 | httpOnly: !env.BETTER_AUTH_URL?.includes("localhost"), // Disable in dev for debugging, enable in prod for security 46 | path: "/", 47 | }, 48 | }, 49 | plugins: [expo()], 50 | }); 51 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | function ScrollArea({ 7 | className, 8 | children, 9 | ...props 10 | }: React.ComponentProps) { 11 | return ( 12 | 17 | 21 | {children} 22 | 23 | 24 | 25 | 26 | ) 27 | } 28 | 29 | function ScrollBar({ 30 | className, 31 | orientation = "vertical", 32 | ...props 33 | }: React.ComponentProps) { 34 | return ( 35 | 48 | 52 | 53 | ) 54 | } 55 | 56 | export { ScrollArea, ScrollBar } 57 | -------------------------------------------------------------------------------- /apps/native/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import { hairlineWidth } from "nativewind/theme"; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export const darkMode = "class"; 5 | export const content = [ 6 | "./app/**/*.{js,ts,tsx}", 7 | "./components/**/*.{js,ts,tsx}", 8 | ]; 9 | export const presets = [require("nativewind/preset")]; 10 | export const theme = { 11 | extend: { 12 | colors: { 13 | background: "hsl(var(--background))", 14 | foreground: "hsl(var(--foreground))", 15 | card: { 16 | DEFAULT: "hsl(var(--card))", 17 | foreground: "hsl(var(--card-foreground))", 18 | }, 19 | popover: { 20 | DEFAULT: "hsl(var(--popover))", 21 | foreground: "hsl(var(--popover-foreground))", 22 | }, 23 | primary: { 24 | DEFAULT: "hsl(var(--primary))", 25 | foreground: "hsl(var(--primary-foreground))", 26 | }, 27 | secondary: { 28 | DEFAULT: "hsl(var(--secondary))", 29 | foreground: "hsl(var(--secondary-foreground))", 30 | }, 31 | muted: { 32 | DEFAULT: "hsl(var(--muted))", 33 | foreground: "hsl(var(--muted-foreground))", 34 | }, 35 | accent: { 36 | DEFAULT: "hsl(var(--accent))", 37 | foreground: "hsl(var(--accent-foreground))", 38 | }, 39 | destructive: { 40 | DEFAULT: "hsl(var(--destructive))", 41 | foreground: "hsl(var(--destructive-foreground))", 42 | }, 43 | border: "hsl(var(--border))", 44 | input: "hsl(var(--input))", 45 | ring: "hsl(var(--ring))", 46 | radius: "var(--radius)", 47 | }, 48 | borderRadius: { 49 | xl: "calc(var(--radius) + 4px)", 50 | lg: "var(--radius)", 51 | md: "calc(var(--radius) - 2px)", 52 | sm: "calc(var(--radius) - 4px)", 53 | }, 54 | borderWidth: { 55 | hairline: hairlineWidth(), 56 | }, 57 | }, 58 | }; 59 | export const plugins = []; 60 | -------------------------------------------------------------------------------- /apps/web/src/components/nav-main.tsx: -------------------------------------------------------------------------------- 1 | import { type Icon, IconCirclePlusFilled, IconMail } from "@tabler/icons-react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | SidebarGroup, 6 | SidebarGroupContent, 7 | SidebarMenu, 8 | SidebarMenuButton, 9 | SidebarMenuItem, 10 | } from "@/components/ui/sidebar"; 11 | 12 | export function NavMain({ 13 | items, 14 | }: { 15 | items: { 16 | title: string; 17 | url: string; 18 | icon?: Icon; 19 | }[]; 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 | 30 | 31 | Quick Create 32 | 33 | 41 | 42 | 43 | 44 | {items.map((item) => ( 45 | 46 | 47 | {item.icon && } 48 | {item.title} 49 | 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /apps/web/src/utils/translated-pathnames.ts: -------------------------------------------------------------------------------- 1 | import type { Locale } from "@/paraglide/runtime.js"; 2 | import type { FileRoutesByTo } from "../routeTree.gen"; 3 | 4 | type RoutePath = keyof FileRoutesByTo; 5 | 6 | const excludedPaths = ["admin", "docs", "api"] as const; 7 | 8 | type PublicRoutePath = Exclude< 9 | RoutePath, 10 | `${string}${(typeof excludedPaths)[number]}${string}` 11 | >; 12 | 13 | type TranslatedPathname = { 14 | pattern: string; 15 | localized: [Locale, string][]; 16 | }; 17 | 18 | function toUrlPattern(path: string) { 19 | return ( 20 | path 21 | // catch-all 22 | .replace(/\/\$$/, "/:path(.*)?") 23 | // optional parameters: {-$param} 24 | .replace(/\{-\$([a-zA-Z0-9_]+)\}/g, ":$1?") 25 | // named parameters: $param 26 | .replace(/\$([a-zA-Z0-9_]+)/g, ":$1") 27 | // remove trailing slash 28 | .replace(/\/+$/, "") 29 | ); 30 | } 31 | 32 | function createTranslatedPathnames( 33 | input: Record> 34 | ): TranslatedPathname[] { 35 | return Object.entries(input).map(([pattern, locales]) => ({ 36 | pattern: toUrlPattern(pattern), 37 | localized: Object.entries(locales).map( 38 | ([locale, path]) => 39 | [locale as Locale, `/${locale}${toUrlPattern(path)}`] satisfies [ 40 | Locale, 41 | string, 42 | ] 43 | ), 44 | })); 45 | } 46 | 47 | export const translatedPathnames = createTranslatedPathnames({ 48 | "/": { 49 | en: "/", 50 | zh: "/", 51 | }, 52 | "/auth": { 53 | en: "/auth", 54 | zh: "/auth", 55 | }, 56 | "/auth/$authView": { 57 | en: "/auth/$authView", 58 | zh: "/auth/$authView", 59 | }, 60 | "/dashboard": { 61 | en: "/dashboard", 62 | zh: "/dashboard", 63 | }, 64 | "/privacy": { 65 | en: "/privacy", 66 | zh: "/privacy", 67 | }, 68 | "/terms": { 69 | en: "/terms", 70 | zh: "/terms", 71 | }, 72 | }); 73 | -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { cloudflare } from "@cloudflare/vite-plugin"; 2 | import { paraglideVitePlugin } from "@inlang/paraglide-js"; 3 | import tailwindcss from "@tailwindcss/vite"; 4 | import { devtools } from "@tanstack/devtools-vite"; 5 | import { tanstackStart } from "@tanstack/react-start/plugin/vite"; 6 | import viteReact from "@vitejs/plugin-react"; 7 | import { defineConfig } from "vite"; 8 | import tsconfigPaths from "vite-tsconfig-paths"; 9 | 10 | export default defineConfig(() => { 11 | // development: .env, .env.local, .env.development, .env.development.local 12 | // production: .env, .env.local, .env.production, .env.production.local 13 | return { 14 | plugins: [ 15 | paraglideVitePlugin({ 16 | project: "./project.inlang", 17 | outdir: "./src/paraglide", 18 | outputStructure: "message-modules", 19 | cookieName: "PARAGLIDE_LOCALE", 20 | strategy: ["url", "cookie", "preferredLanguage", "baseLocale"], 21 | urlPatterns: [ 22 | { 23 | pattern: "/", 24 | localized: [ 25 | ["en", "/"], 26 | ["zh", "/zh"], 27 | ], 28 | }, 29 | { 30 | pattern: "/:path(.*)?", 31 | localized: [ 32 | ["en", "/en/:path(.*)?"], 33 | ["zh", "/zh/:path(.*)?"], 34 | ], 35 | }, 36 | ], 37 | }), 38 | cloudflare({ viteEnvironment: { name: "ssr" } }), 39 | devtools(), 40 | tsconfigPaths(), 41 | tailwindcss(), 42 | tanstackStart({}), 43 | viteReact({ 44 | // https://react.dev/learn/react-compiler 45 | babel: { 46 | plugins: [ 47 | [ 48 | "babel-plugin-react-compiler", 49 | { 50 | target: "19", 51 | }, 52 | ], 53 | ], 54 | }, 55 | }), 56 | ], 57 | }; 58 | }); 59 | -------------------------------------------------------------------------------- /apps/web/src/components/language-switcher.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Globe } from "lucide-react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { 4 | DropdownMenu, 5 | DropdownMenuContent, 6 | DropdownMenuItem, 7 | DropdownMenuLabel, 8 | DropdownMenuSeparator, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { webConfig } from "@/configs/web-config"; 12 | import { m } from "@/paraglide/messages"; 13 | import { getLocale, setLocale } from "@/paraglide/runtime"; 14 | 15 | const { i18n } = webConfig; 16 | 17 | export default function LanguageSwitcher() { 18 | const locales = i18n.locales; 19 | 20 | if (!Object.keys(locales).length) { 21 | return null; 22 | } 23 | 24 | const currentLanguage = getLocale(); 25 | 26 | const handleLanguageChange = (langCode: "en" | "zh") => { 27 | setLocale(langCode); 28 | }; 29 | 30 | return ( 31 | 32 | 33 | 39 | 40 | 41 | {m["language.switcher"]()} 42 | 43 | {Object.entries(locales).map(([key, value]) => ( 44 | handleLanguageChange(key as "en" | "zh")} 47 | > 48 | {value.flag} 49 | {value.name} 50 | {key === currentLanguage && ( 51 | 52 | )} 53 | 54 | ))} 55 | 56 | 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/web/src/components/user-menu.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { 3 | DropdownMenu, 4 | DropdownMenuContent, 5 | DropdownMenuItem, 6 | DropdownMenuLabel, 7 | DropdownMenuSeparator, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { useSignOut } from "@/hooks/use-sign-out"; 11 | import { authClient } from "@/lib/auth/auth-client"; 12 | import { Button } from "./ui/button"; 13 | import { Skeleton } from "./ui/skeleton"; 14 | 15 | export default function UserMenu() { 16 | const { data: session, isPending } = authClient.useSession(); 17 | const { signOut } = useSignOut(); 18 | 19 | if (isPending) { 20 | return ; 21 | } 22 | 23 | if (!session) { 24 | return ( 25 | 30 | ); 31 | } 32 | 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | My Account 40 | 41 | {session.user.email} 42 | 43 |
44 |

Sign Out

45 |
46 |
47 | {/* dashboard */} 48 | 49 | 50 |
51 |

Dashboard

52 |
53 | 54 |
55 |
56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /apps/server/src/db/schema/auth.ts: -------------------------------------------------------------------------------- 1 | import { boolean, pgTable, text, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const user = pgTable("user", { 4 | id: text("id").primaryKey(), 5 | name: text("name").notNull(), 6 | email: text("email").notNull().unique(), 7 | emailVerified: boolean("email_verified").notNull(), 8 | image: text("image"), 9 | createdAt: timestamp("created_at").notNull(), 10 | updatedAt: timestamp("updated_at").notNull(), 11 | }); 12 | 13 | export const session = pgTable("session", { 14 | id: text("id").primaryKey(), 15 | expiresAt: timestamp("expires_at").notNull(), 16 | token: text("token").notNull().unique(), 17 | createdAt: timestamp("created_at").notNull(), 18 | updatedAt: timestamp("updated_at").notNull(), 19 | ipAddress: text("ip_address"), 20 | userAgent: text("user_agent"), 21 | userId: text("user_id") 22 | .notNull() 23 | .references(() => user.id, { onDelete: "cascade" }), 24 | }); 25 | 26 | export const account = pgTable("account", { 27 | id: text("id").primaryKey(), 28 | accountId: text("account_id").notNull(), 29 | providerId: text("provider_id").notNull(), 30 | userId: text("user_id") 31 | .notNull() 32 | .references(() => user.id, { onDelete: "cascade" }), 33 | accessToken: text("access_token"), 34 | refreshToken: text("refresh_token"), 35 | idToken: text("id_token"), 36 | accessTokenExpiresAt: timestamp("access_token_expires_at"), 37 | refreshTokenExpiresAt: timestamp("refresh_token_expires_at"), 38 | scope: text("scope"), 39 | password: text("password"), 40 | createdAt: timestamp("created_at").notNull(), 41 | updatedAt: timestamp("updated_at").notNull(), 42 | }); 43 | 44 | export const verification = pgTable("verification", { 45 | id: text("id").primaryKey(), 46 | identifier: text("identifier").notNull(), 47 | value: text("value").notNull(), 48 | expiresAt: timestamp("expires_at").notNull(), 49 | createdAt: timestamp("created_at"), 50 | updatedAt: timestamp("updated_at"), 51 | }); 52 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@biomejs/biome/configuration_schema.json", 3 | "files": { 4 | "ignoreUnknown": false, 5 | "includes": [ 6 | "**", 7 | "!**/.next", 8 | "!**/dist", 9 | "!**/.turbo", 10 | "!**/dev-dist", 11 | "!**/.zed", 12 | "!**/.vscode", 13 | "!**/routeTree.gen.ts", 14 | "!**/src-tauri", 15 | "!**/.nuxt", 16 | "!bts.jsonc", 17 | "!**/.expo", 18 | "!**/.wrangler", 19 | "!**/.alchemy", 20 | "!**/.svelte-kit", 21 | "!**/wrangler.jsonc", 22 | "!**/.source", 23 | "!**/node_modules", 24 | "!**/tsconfig.json", 25 | "!**/global.css", 26 | "!**/index.css", 27 | "!**/worker-configuration.d.ts", 28 | "!**/deploy-all.mjs", 29 | "!**/paraglide", 30 | "!**/apps/web/src/components/ui/*.tsx" 31 | ] 32 | }, 33 | "extends": ["ultracite"], 34 | "linter": { 35 | "rules": { 36 | "style": { 37 | "noMagicNumbers": "off", 38 | "useNamingConvention": "off", 39 | "noHeadElement": "off", 40 | "noNestedTernary": "off", 41 | "useFilenamingConvention": "off" 42 | }, 43 | "nursery": { 44 | "useConsistentTypeDefinitions": "off", 45 | "noShadow": "off", 46 | "noUselessUndefined": "off" 47 | }, 48 | "performance": { 49 | "noNamespaceImport": "off", 50 | "useTopLevelRegex": "off" 51 | }, 52 | "suspicious": { 53 | "noConsole": "off", 54 | "noDocumentCookie": "off" 55 | }, 56 | "correctness": { 57 | "useExhaustiveDependencies": "off" 58 | }, 59 | "a11y": { 60 | "useValidAnchor": "off", 61 | "useFocusableInteractive": "off", 62 | "useSemanticElements": "off" 63 | }, 64 | "complexity": { 65 | "noExcessiveCognitiveComplexity": "off" 66 | }, 67 | "security": { 68 | "noDangerouslySetInnerHtml": "off" 69 | } 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /apps/web/src/routes/_public/index.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { createFileRoute } from "@tanstack/react-router"; 3 | import { orpc } from "@/utils/orpc"; 4 | 5 | export const Route = createFileRoute("/_public/")({ 6 | component: HomeComponent, 7 | }); 8 | 9 | const TITLE_TEXT = ` 10 | ██████╗ ███████╗████████╗████████╗███████╗██████╗ 11 | ██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗ 12 | ██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝ 13 | ██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗ 14 | ██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║ 15 | ╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝ 16 | 17 | ████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗ 18 | ╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝ 19 | ██║ ███████╗ ██║ ███████║██║ █████╔╝ 20 | ██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗ 21 | ██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗ 22 | ╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝ 23 | `; 24 | 25 | function HomeComponent() { 26 | const healthCheck = useQuery(orpc.healthCheck.queryOptions()); 27 | 28 | return ( 29 |
30 |
{TITLE_TEXT}
31 |
32 |
33 |

API Status

34 |
35 |
38 | 39 | {healthCheck.isLoading 40 | ? "Checking..." 41 | : healthCheck.data 42 | ? "Connected" 43 | : "Disconnected"} 44 | 45 |
46 |
47 |
48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /apps/web/src/components/confirm-dialog.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | AlertDialog, 3 | AlertDialogCancel, 4 | AlertDialogContent, 5 | AlertDialogDescription, 6 | AlertDialogFooter, 7 | AlertDialogHeader, 8 | AlertDialogTitle, 9 | } from "@/components/ui/alert-dialog"; 10 | import { Button } from "@/components/ui/button"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | type ConfirmDialogProps = { 14 | open: boolean; 15 | onOpenChange: (open: boolean) => void; 16 | title: React.ReactNode; 17 | disabled?: boolean; 18 | desc: React.JSX.Element | string; 19 | cancelBtnText?: string; 20 | confirmText?: React.ReactNode; 21 | destructive?: boolean; 22 | handleConfirm: () => void; 23 | isLoading?: boolean; 24 | className?: string; 25 | children?: React.ReactNode; 26 | }; 27 | 28 | export function ConfirmDialog(props: ConfirmDialogProps) { 29 | const { 30 | title, 31 | desc, 32 | children, 33 | className, 34 | confirmText, 35 | cancelBtnText, 36 | destructive, 37 | isLoading, 38 | disabled = false, 39 | handleConfirm, 40 | ...actions 41 | } = props; 42 | return ( 43 | 44 | 45 | 46 | {title} 47 | 48 |
{desc}
49 |
50 |
51 | {children} 52 | 53 | 54 | {cancelBtnText ?? "Cancel"} 55 | 56 | 63 | 64 |
65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as TooltipPrimitive from "@radix-ui/react-tooltip"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function TooltipProvider({ 7 | delayDuration = 0, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function Tooltip({ 20 | ...props 21 | }: React.ComponentProps) { 22 | return ( 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | function TooltipTrigger({ 30 | ...props 31 | }: React.ComponentProps) { 32 | return ; 33 | } 34 | 35 | function TooltipContent({ 36 | className, 37 | sideOffset = 0, 38 | children, 39 | ...props 40 | }: React.ComponentProps) { 41 | return ( 42 | 43 | 52 | {children} 53 | 54 | 55 | 56 | ); 57 | } 58 | 59 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; 60 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/toggle-group.tsx: -------------------------------------------------------------------------------- 1 | import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; 2 | import type { VariantProps } from "class-variance-authority"; 3 | import * as React from "react"; 4 | import { toggleVariants } from "@/components/ui/toggle"; 5 | import { cn } from "@/lib/utils"; 6 | 7 | const ToggleGroupContext = React.createContext< 8 | VariantProps 9 | >({ 10 | size: "default", 11 | variant: "default", 12 | }); 13 | 14 | function ToggleGroup({ 15 | className, 16 | variant, 17 | size, 18 | children, 19 | ...props 20 | }: React.ComponentProps & 21 | VariantProps) { 22 | return ( 23 | 33 | 34 | {children} 35 | 36 | 37 | ); 38 | } 39 | 40 | function ToggleGroupItem({ 41 | className, 42 | children, 43 | variant, 44 | size, 45 | ...props 46 | }: React.ComponentProps & 47 | VariantProps) { 48 | const context = React.useContext(ToggleGroupContext); 49 | 50 | return ( 51 | 65 | {children} 66 | 67 | ); 68 | } 69 | 70 | export { ToggleGroup, ToggleGroupItem }; 71 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged 2 | 3 | #!/bin/sh 4 | # Exit on any error 5 | set -e 6 | 7 | # Check if there are any staged files 8 | if [ -z "$(git diff --cached --name-only)" ]; then 9 | echo "No staged files to format" 10 | exit 0 11 | fi 12 | 13 | # Store the hash of staged changes to detect modifications 14 | STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1) 15 | 16 | # Save list of staged files (handling all file states) 17 | STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACMR) 18 | PARTIALLY_STAGED=$(git diff --name-only) 19 | 20 | # Stash unstaged changes to preserve working directory 21 | # --keep-index keeps staged changes in working tree 22 | git stash push --quiet --keep-index --message "pre-commit-stash" || true 23 | STASHED=$? 24 | 25 | # Run formatter on the staged files 26 | pnpm dlx ultracite fix 27 | FORMAT_EXIT_CODE=$? 28 | 29 | # Restore working directory state 30 | if [ $STASHED -eq 0 ]; then 31 | # Re-stage the formatted files 32 | if [ -n "$STAGED_FILES" ]; then 33 | echo "$STAGED_FILES" | while IFS= read -r file; do 34 | if [ -f "$file" ]; then 35 | git add "$file" 36 | fi 37 | done 38 | fi 39 | 40 | # Restore unstaged changes 41 | git stash pop --quiet || true 42 | 43 | # Restore partial staging if files were partially staged 44 | if [ -n "$PARTIALLY_STAGED" ]; then 45 | for file in $PARTIALLY_STAGED; do 46 | if [ -f "$file" ] && echo "$STAGED_FILES" | grep -q "^$file$"; then 47 | # File was partially staged - need to unstage the unstaged parts 48 | git restore --staged "$file" 2>/dev/null || true 49 | git add -p "$file" < /dev/null 2>/dev/null || git add "$file" 50 | fi 51 | done 52 | fi 53 | else 54 | # No stash was created, just re-add the formatted files 55 | if [ -n "$STAGED_FILES" ]; then 56 | echo "$STAGED_FILES" | while IFS= read -r file; do 57 | if [ -f "$file" ]; then 58 | git add "$file" 59 | fi 60 | done 61 | fi 62 | fi 63 | 64 | # Check if staged files actually changed 65 | NEW_STAGED_HASH=$(git diff --cached | sha256sum | cut -d' ' -f1) 66 | if [ "$STAGED_HASH" != "$NEW_STAGED_HASH" ]; then 67 | echo "✨ Files formatted by Ultracite" 68 | fi 69 | 70 | exit $FORMAT_EXIT_CODE 71 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as TabsPrimitive from "@radix-ui/react-tabs"; 2 | import type * as React from "react"; 3 | 4 | import { cn } from "@/lib/utils"; 5 | 6 | function Tabs({ 7 | className, 8 | ...props 9 | }: React.ComponentProps) { 10 | return ( 11 | 16 | ); 17 | } 18 | 19 | function TabsList({ 20 | className, 21 | ...props 22 | }: React.ComponentProps) { 23 | return ( 24 | 32 | ); 33 | } 34 | 35 | function TabsTrigger({ 36 | className, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 48 | ); 49 | } 50 | 51 | function TabsContent({ 52 | className, 53 | ...props 54 | }: React.ComponentProps) { 55 | return ( 56 | 61 | ); 62 | } 63 | 64 | export { Tabs, TabsList, TabsTrigger, TabsContent }; 65 | -------------------------------------------------------------------------------- /apps/web/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import { TanStackDevtools } from "@tanstack/react-devtools"; 2 | import type { QueryClient } from "@tanstack/react-query"; 3 | import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; 4 | import { 5 | createRootRouteWithContext, 6 | HeadContent, 7 | Outlet, 8 | Scripts, 9 | useRouterState, 10 | } from "@tanstack/react-router"; 11 | import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; 12 | import { ThemeProvider } from "@/components/context/theme-provider"; 13 | import Loader from "@/components/loader"; 14 | import { Toaster } from "@/components/ui/sonner"; 15 | import { getLocale } from "@/paraglide/runtime.js"; 16 | import type { orpc } from "@/utils/orpc"; 17 | import appCss from "../index.css?url"; 18 | 19 | export interface RouterAppContext { 20 | orpc: typeof orpc; 21 | queryClient: QueryClient; 22 | } 23 | 24 | export const Route = createRootRouteWithContext()({ 25 | head: () => ({ 26 | meta: [ 27 | { 28 | charSet: "utf-8", 29 | }, 30 | { 31 | name: "viewport", 32 | content: "width=device-width, initial-scale=1", 33 | }, 34 | { 35 | title: "ShipFullStack", 36 | }, 37 | ], 38 | links: [ 39 | { 40 | rel: "stylesheet", 41 | href: appCss, 42 | }, 43 | ], 44 | }), 45 | 46 | component: RootDocument, 47 | }); 48 | 49 | function RootDocument() { 50 | const isFetching = useRouterState({ select: (s) => s.isLoading }); 51 | return ( 52 | 53 | 54 | 55 | 56 | 57 | 58 | {isFetching ? : } 59 | 60 | , 65 | }, 66 | { 67 | name: "TanStack Router", 68 | render: , 69 | }, 70 | ]} 71 | /> 72 | 73 | 74 | 75 | 76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /apps/web/src/components/theme-switch.tsx: -------------------------------------------------------------------------------- 1 | import { Check, Moon, Sun } from "lucide-react"; 2 | import { useEffect } from "react"; 3 | import { useTheme } from "@/components/context/theme-provider"; 4 | import { Button } from "@/components/ui/button"; 5 | import { 6 | DropdownMenu, 7 | DropdownMenuContent, 8 | DropdownMenuItem, 9 | DropdownMenuTrigger, 10 | } from "@/components/ui/dropdown-menu"; 11 | import { cn } from "@/lib/utils"; 12 | 13 | export function ThemeSwitch() { 14 | const { userTheme, setTheme } = useTheme(); 15 | 16 | /* Update theme-color meta tag 17 | * when theme is updated */ 18 | useEffect(() => { 19 | const themeColor = userTheme === "dark" ? "#020817" : "#fff"; 20 | const metaThemeColor = document.querySelector("meta[name='theme-color']"); 21 | if (metaThemeColor) { 22 | metaThemeColor.setAttribute("content", themeColor); 23 | } 24 | }, [userTheme]); 25 | 26 | return ( 27 | 28 | 29 | 34 | 35 | 36 | setTheme("light")}> 37 | Light{" "} 38 | 42 | 43 | setTheme("dark")}> 44 | Dark 45 | 49 | 50 | setTheme("system")}> 51 | System 52 | 56 | 57 | 58 | 59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", 9 | { 10 | variants: { 11 | variant: { 12 | default: "bg-primary text-primary-foreground hover:bg-primary/90", 13 | destructive: 14 | "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 15 | outline: 16 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 17 | secondary: 18 | "bg-secondary text-secondary-foreground hover:bg-secondary/80", 19 | ghost: 20 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 25 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 26 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 27 | icon: "size-9", 28 | "icon-sm": "size-8", 29 | "icon-lg": "size-10", 30 | }, 31 | }, 32 | defaultVariants: { 33 | variant: "default", 34 | size: "default", 35 | }, 36 | } 37 | ) 38 | 39 | function Button({ 40 | className, 41 | variant, 42 | size, 43 | asChild = false, 44 | ...props 45 | }: React.ComponentProps<"button"> & 46 | VariantProps & { 47 | asChild?: boolean 48 | }) { 49 | const Comp = asChild ? Slot : "button" 50 | 51 | return ( 52 | 57 | ) 58 | } 59 | 60 | export { Button, buttonVariants } 61 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import type * as React from "react"; 2 | 3 | import { cn } from "@/lib/utils"; 4 | 5 | function Card({ className, ...props }: React.ComponentProps<"div">) { 6 | return ( 7 |
15 | ); 16 | } 17 | 18 | function CardHeader({ className, ...props }: React.ComponentProps<"div">) { 19 | return ( 20 |
28 | ); 29 | } 30 | 31 | function CardTitle({ className, ...props }: React.ComponentProps<"div">) { 32 | return ( 33 |
38 | ); 39 | } 40 | 41 | function CardDescription({ className, ...props }: React.ComponentProps<"div">) { 42 | return ( 43 |
48 | ); 49 | } 50 | 51 | function CardAction({ className, ...props }: React.ComponentProps<"div">) { 52 | return ( 53 |
61 | ); 62 | } 63 | 64 | function CardContent({ className, ...props }: React.ComponentProps<"div">) { 65 | return ( 66 |
71 | ); 72 | } 73 | 74 | function CardFooter({ className, ...props }: React.ComponentProps<"div">) { 75 | return ( 76 |
81 | ); 82 | } 83 | 84 | export { 85 | Card, 86 | CardHeader, 87 | CardFooter, 88 | CardTitle, 89 | CardAction, 90 | CardDescription, 91 | CardContent, 92 | }; 93 | -------------------------------------------------------------------------------- /.github/workflows/preview.yml: -------------------------------------------------------------------------------- 1 | name: Preview Deployment 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, reopened] 6 | 7 | env: 8 | NODE_VERSION: "20" 9 | 10 | jobs: 11 | # 检测变更 12 | detect-changes: 13 | name: Detect Changes 14 | runs-on: ubuntu-latest 15 | outputs: 16 | web: ${{ steps.filter.outputs.web }} 17 | server: ${{ steps.filter.outputs.server }} 18 | steps: 19 | - name: Checkout code 20 | uses: actions/checkout@v4 21 | 22 | - name: Detect file changes 23 | uses: dorny/paths-filter@v3 24 | id: filter 25 | with: 26 | filters: | 27 | web: 28 | - 'apps/web/**' 29 | - 'package.json' 30 | - 'pnpm-lock.yaml' 31 | server: 32 | - 'apps/server/**' 33 | - 'package.json' 34 | - 'pnpm-lock.yaml' 35 | 36 | # 构建和测试 37 | build-and-test: 38 | name: Build & Test 39 | runs-on: ubuntu-latest 40 | needs: detect-changes 41 | if: needs.detect-changes.outputs.web == 'true' || needs.detect-changes.outputs.server == 'true' 42 | 43 | steps: 44 | - name: Checkout code 45 | uses: actions/checkout@v4 46 | 47 | - name: Setup pnpm 48 | uses: pnpm/action-setup@v4 49 | 50 | - name: Setup Node.js 51 | uses: actions/setup-node@v4 52 | with: 53 | node-version: ${{ env.NODE_VERSION }} 54 | cache: "pnpm" 55 | 56 | - name: Install dependencies 57 | run: pnpm install --frozen-lockfile 58 | 59 | - name: Type check 60 | run: pnpm run check-types 61 | continue-on-error: true 62 | 63 | - name: Lint & Format check 64 | run: pnpm run check 65 | continue-on-error: true 66 | 67 | - name: Build web (if changed) 68 | if: needs.detect-changes.outputs.web == 'true' 69 | run: pnpm run build 70 | working-directory: ./apps/web 71 | 72 | - name: Build server (if changed) 73 | if: needs.detect-changes.outputs.server == 'true' 74 | run: pnpm run build 75 | working-directory: ./apps/server 76 | 77 | - name: Summary 78 | if: success() 79 | run: | 80 | echo "### ✅ Build Successful" >> $GITHUB_STEP_SUMMARY 81 | echo "" >> $GITHUB_STEP_SUMMARY 82 | echo "All checks passed! Ready for review." >> $GITHUB_STEP_SUMMARY 83 | -------------------------------------------------------------------------------- /apps/native/app/_layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DarkTheme, 3 | DefaultTheme, 4 | type Theme, 5 | ThemeProvider, 6 | } from "@react-navigation/native"; 7 | import { QueryClientProvider } from "@tanstack/react-query"; 8 | import { Stack } from "expo-router"; 9 | import { StatusBar } from "expo-status-bar"; 10 | import { GestureHandlerRootView } from "react-native-gesture-handler"; 11 | import "../global.css"; 12 | import React, { useRef } from "react"; 13 | import { Platform } from "react-native"; 14 | import { setAndroidNavigationBar } from "@/lib/android-navigation-bar"; 15 | import { NAV_THEME } from "@/lib/constants"; 16 | import { useColorScheme } from "@/lib/use-color-scheme"; 17 | import { queryClient } from "@/utils/orpc"; 18 | 19 | const LIGHT_THEME: Theme = { 20 | ...DefaultTheme, 21 | colors: NAV_THEME.light, 22 | }; 23 | const DARK_THEME: Theme = { 24 | ...DarkTheme, 25 | colors: NAV_THEME.dark, 26 | }; 27 | 28 | export const unstable_settings = { 29 | initialRouteName: "(drawer)", 30 | }; 31 | 32 | export default function RootLayout() { 33 | const hasMounted = useRef(false); 34 | const { colorScheme, isDarkColorScheme } = useColorScheme(); 35 | const [isColorSchemeLoaded, setIsColorSchemeLoaded] = React.useState(false); 36 | 37 | useIsomorphicLayoutEffect(() => { 38 | if (hasMounted.current) { 39 | return; 40 | } 41 | 42 | if (Platform.OS === "web") { 43 | document.documentElement.classList.add("bg-background"); 44 | } 45 | setAndroidNavigationBar(colorScheme); 46 | setIsColorSchemeLoaded(true); 47 | hasMounted.current = true; 48 | }, []); 49 | 50 | if (!isColorSchemeLoaded) { 51 | return null; 52 | } 53 | return ( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 64 | 65 | 66 | 67 | 68 | ); 69 | } 70 | 71 | const useIsomorphicLayoutEffect = 72 | Platform.OS === "web" && typeof window === "undefined" 73 | ? React.useEffect 74 | : React.useLayoutEffect; 75 | -------------------------------------------------------------------------------- /apps/server/src/index.ts: -------------------------------------------------------------------------------- 1 | import { Hono } from "hono"; 2 | import { logger } from "hono/logger"; 3 | import { apiHandler } from "./handlers/api"; 4 | import { rpcHandler } from "./handlers/rpc"; 5 | import { auth } from "./lib/auth"; 6 | import { createContext } from "./lib/context"; 7 | import { apiCorsMiddleware, authCorsMiddleware } from "./middlewares/cors"; 8 | import { errorHandler } from "./middlewares/error"; 9 | import { sessionMiddleware } from "./middlewares/session"; 10 | import { stripTenantPrefixFromRequest } from "./utils/tenant"; 11 | 12 | const app = new Hono<{ 13 | Variables: { 14 | user: typeof auth.$Infer.Session.user | null; 15 | session: typeof auth.$Infer.Session.session | null; 16 | }; 17 | }>(); 18 | 19 | // Global error handler 20 | app.onError(errorHandler); 21 | 22 | // Logger middleware 23 | app.use(logger()); 24 | 25 | // Session middleware for API and RPC routes 26 | app.use("/api/*", sessionMiddleware); 27 | app.use("/rpc/*", sessionMiddleware); 28 | 29 | // CORS for auth endpoints 30 | app.use("/api/auth/*", authCorsMiddleware); 31 | 32 | // Better Auth handler 33 | app.on(["POST", "GET"], "/api/auth/*", (c) => auth.handler(c.req.raw)); 34 | 35 | // CORS for API and RPC endpoints 36 | app.use("/rpc/*", apiCorsMiddleware); 37 | app.use("/api/*", apiCorsMiddleware); 38 | 39 | // RPC and API handler 40 | app.use("/*", async (c, next) => { 41 | const context = await createContext({ context: c }); 42 | const normalizedRequest = stripTenantPrefixFromRequest(c.req.raw); 43 | 44 | const rpcResult = await rpcHandler.handle(normalizedRequest, { 45 | prefix: "/rpc", 46 | context, 47 | }); 48 | 49 | if (rpcResult.matched) { 50 | return c.newResponse(rpcResult.response.body, rpcResult.response); 51 | } 52 | 53 | const apiResult = await apiHandler.handle(normalizedRequest, { 54 | prefix: "/api", 55 | context, 56 | }); 57 | 58 | if (apiResult.matched) { 59 | return c.newResponse(apiResult.response.body, apiResult.response); 60 | } 61 | 62 | await next(); 63 | }); 64 | 65 | // Health check endpoint 66 | app.get("/", (c) => 67 | c.json({ 68 | status: "ok", 69 | service: "ShipFullStack API", 70 | timestamp: new Date().toISOString(), 71 | }) 72 | ); 73 | 74 | app.get("/session", (c) => { 75 | const session = c.get("session"); 76 | const user = c.get("user"); 77 | 78 | if (!user) { 79 | return c.body(null, 401); 80 | } 81 | 82 | return c.json({ 83 | session, 84 | user, 85 | }); 86 | }); 87 | 88 | export default app; 89 | -------------------------------------------------------------------------------- /.ruler/bts.md: -------------------------------------------------------------------------------- 1 | # Better-T-Stack Project Rules 2 | 3 | This is a easyapp-fullstack-template project created with Better-T-Stack CLI. 4 | 5 | ## Project Structure 6 | 7 | This is a monorepo with the following structure: 8 | 9 | 10 | - **`apps/server/`** - Backend server (Hono) 11 | 12 | - **`apps/native/`** - React Native mobile app (with NativeWind) 13 | 14 | ## Available Scripts 15 | 16 | - `pnpm run dev` - Start all apps in development mode 17 | - `pnpm run dev:server` - Start only the server 18 | - `pnpm run dev:native` - Start only the native app 19 | 20 | ## Database Commands 21 | 22 | All database operations should be run from the server workspace: 23 | 24 | - `pnpm run db:push` - Push schema changes to database 25 | - `pnpm run db:studio` - Open database studio 26 | - `pnpm run db:generate` - Generate Drizzle files 27 | - `pnpm run db:migrate` - Run database migrations 28 | 29 | Database schema files are located in `apps/server/src/db/schema/` 30 | 31 | ## API Structure 32 | 33 | - oRPC endpoints are in `apps/server/src/api/` 34 | - Client-side API utils are in `apps/web/src/utils/api.ts` 35 | 36 | ## Authentication 37 | 38 | Authentication is enabled in this project: 39 | - Server auth logic is in `apps/server/src/lib/auth.ts` 40 | - Native app auth client is in `apps/native/src/lib/auth-client.ts` 41 | 42 | ## Adding More Features 43 | 44 | You can add additional addons or deployment options to your project using: 45 | 46 | ```bash 47 | pnpx create-better-t-stack 48 | add 49 | ``` 50 | 51 | Available addons you can add: 52 | - **Documentation**: Starlight, Fumadocs 53 | - **Linting**: Biome, Oxlint, Ultracite 54 | - **Other**: Ruler, Turborepo, PWA, Tauri, Husky 55 | 56 | You can also add web deployment configurations like Cloudflare Workers support. 57 | 58 | ## Project Configuration 59 | 60 | This project includes a `bts.jsonc` configuration file that stores your Better-T-Stack settings: 61 | 62 | - Contains your selected stack configuration (database, ORM, backend, frontend, etc.) 63 | - Used by the CLI to understand your project structure 64 | - Safe to delete if not needed 65 | - Updated automatically when using the `add` command 66 | 67 | ## Key Points 68 | 69 | - This is a Turborepo monorepo using pnpm workspaces 70 | - Each app has its own `package.json` and dependencies 71 | - Run commands from the root to execute across all workspaces 72 | - Run workspace-specific commands with `pnpm run command-name` 73 | - Turborepo handles build caching and parallel execution 74 | - Use `pnpx 75 | create-better-t-stack add` to add more features later 76 | -------------------------------------------------------------------------------- /apps/web/src/routes/(auth)/auth/$authView.tsx: -------------------------------------------------------------------------------- 1 | import { AuthView } from "@daveyplate/better-auth-ui"; 2 | import { createFileRoute, Link, redirect } from "@tanstack/react-router"; 3 | import { authClient } from "@/lib/auth/auth-client"; 4 | import { m } from "@/paraglide/messages"; 5 | import { localizeHref } from "@/paraglide/runtime"; 6 | 7 | export const Route = createFileRoute("/(auth)/auth/$authView")({ 8 | beforeLoad: async () => { 9 | const session = await authClient.getSession(); 10 | if (session.data?.user) { 11 | redirect({ 12 | to: "/dashboard", 13 | throw: true, 14 | }); 15 | } 16 | }, 17 | component: RouteComponent, 18 | }); 19 | 20 | function RouteComponent() { 21 | const { authView } = Route.useParams(); 22 | 23 | const isSignUp = authView === "sign-up"; 24 | 25 | // Build full callback URL for OAuth redirects 26 | const appUrl = import.meta.env.VITE_APP_URL || window.location.origin; 27 | const callbackUrl = `${appUrl}${localizeHref("/auth/callback")}`; 28 | 29 | return ( 30 |
31 | 49 | {isSignUp && ( 50 |

51 | {m["auth.sign_up.clicking_continue"]()}{" "} 52 | 56 | {m["auth.sign_up.terms_of_service"]()} 57 | {" "} 58 | {m["auth.sign_up.and"]()}{" "} 59 | 63 | {m["auth.sign_up.privacy_policy"]()} 64 | 65 |

66 | )} 67 |
68 | ); 69 | } 70 | -------------------------------------------------------------------------------- /apps/web/src/components/context/layout-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | import { getCookie, setCookie } from "@/lib/cookies"; 3 | 4 | export type Collapsible = "offcanvas" | "icon" | "none"; 5 | export type Variant = "inset" | "sidebar" | "floating"; 6 | 7 | // Cookie constants following the pattern from sidebar.tsx 8 | const LAYOUT_COLLAPSIBLE_COOKIE_NAME = "layout_collapsible"; 9 | const LAYOUT_VARIANT_COOKIE_NAME = "layout_variant"; 10 | const LAYOUT_COOKIE_MAX_AGE = 60 * 60 * 24 * 7; // 7 days 11 | 12 | // Default values 13 | const DEFAULT_VARIANT = "inset"; 14 | const DEFAULT_COLLAPSIBLE = "icon"; 15 | 16 | type LayoutContextType = { 17 | resetLayout: () => void; 18 | 19 | defaultCollapsible: Collapsible; 20 | collapsible: Collapsible; 21 | setCollapsible: (collapsible: Collapsible) => void; 22 | 23 | defaultVariant: Variant; 24 | variant: Variant; 25 | setVariant: (variant: Variant) => void; 26 | }; 27 | 28 | const LayoutContext = createContext(null); 29 | 30 | type LayoutProviderProps = { 31 | children: React.ReactNode; 32 | }; 33 | 34 | export function LayoutProvider({ children }: LayoutProviderProps) { 35 | const [collapsible, _setCollapsible] = useState(() => { 36 | const saved = getCookie(LAYOUT_COLLAPSIBLE_COOKIE_NAME); 37 | return (saved as Collapsible) || DEFAULT_COLLAPSIBLE; 38 | }); 39 | 40 | const [variant, _setVariant] = useState(() => { 41 | const saved = getCookie(LAYOUT_VARIANT_COOKIE_NAME); 42 | return (saved as Variant) || DEFAULT_VARIANT; 43 | }); 44 | 45 | const setCollapsible = (newCollapsible: Collapsible) => { 46 | _setCollapsible(newCollapsible); 47 | setCookie( 48 | LAYOUT_COLLAPSIBLE_COOKIE_NAME, 49 | newCollapsible, 50 | LAYOUT_COOKIE_MAX_AGE 51 | ); 52 | }; 53 | 54 | const setVariant = (newVariant: Variant) => { 55 | _setVariant(newVariant); 56 | setCookie(LAYOUT_VARIANT_COOKIE_NAME, newVariant, LAYOUT_COOKIE_MAX_AGE); 57 | }; 58 | 59 | const resetLayout = () => { 60 | setCollapsible(DEFAULT_COLLAPSIBLE); 61 | setVariant(DEFAULT_VARIANT); 62 | }; 63 | 64 | const contextValue: LayoutContextType = { 65 | resetLayout, 66 | defaultCollapsible: DEFAULT_COLLAPSIBLE, 67 | collapsible, 68 | setCollapsible, 69 | defaultVariant: DEFAULT_VARIANT, 70 | variant, 71 | setVariant, 72 | }; 73 | 74 | return {children}; 75 | } 76 | 77 | // Define the hook for the provider 78 | // eslint-disable-next-line react-refresh/only-export-components 79 | export function useLayout() { 80 | const context = useContext(LayoutContext); 81 | if (!context) { 82 | throw new Error("useLayout must be used within a LayoutProvider"); 83 | } 84 | return context; 85 | } 86 | -------------------------------------------------------------------------------- /apps/server/README.md: -------------------------------------------------------------------------------- 1 | # Server Structure 2 | 3 | ## 📁 Directory Structure 4 | 5 | ``` 6 | apps/server/src/ 7 | ├── handlers/ # Request handlers 8 | │ ├── api.ts # OpenAPI handler configuration 9 | │ ├── rpc.ts # RPC handler configuration 10 | │ └── index.ts # Handler exports 11 | ├── middlewares/ # Reusable middleware 12 | │ ├── cors.ts # CORS configurations 13 | │ ├── error.ts # Global error handler 14 | │ ├── session.ts # Session authentication middleware 15 | │ └── index.ts # Middleware exports 16 | ├── utils/ # Utility functions 17 | │ └── tenant.ts # Multi-tenant request handling 18 | ├── lib/ # Core libraries 19 | │ ├── auth.ts # Better Auth configuration 20 | │ └── context.ts # Request context creator 21 | ├── routers/ # API route definitions 22 | │ └── index.ts 23 | ├── db/ # Database configuration 24 | └── index.ts # Main application entry point 25 | ``` 26 | 27 | ## 🎯 Key Benefits 28 | 29 | ### Before Refactoring 30 | - ❌ 154 lines in main file 31 | - ❌ All logic mixed together 32 | - ❌ Hard to test individual middleware 33 | - ❌ Difficult to maintain 34 | 35 | ### After Refactoring 36 | - ✅ 89 lines in main file (42% reduction) 37 | - ✅ Separated concerns 38 | - ✅ Easy to test each module 39 | - ✅ Clear, maintainable structure 40 | 41 | ## 📖 Usage Examples 42 | 43 | ### Adding New Middleware 44 | 45 | ```typescript 46 | // 1. Create new middleware file 47 | // src/middlewares/rate-limit.ts 48 | import type { MiddlewareHandler } from "hono"; 49 | 50 | export const rateLimitMiddleware: MiddlewareHandler = async (c, next) => { 51 | // Your logic here 52 | await next(); 53 | }; 54 | 55 | // 2. Export from index 56 | // src/middlewares/index.ts 57 | export { rateLimitMiddleware } from "./rate-limit"; 58 | 59 | // 3. Use in main app 60 | // src/index.ts 61 | import { rateLimitMiddleware } from "./middlewares"; 62 | app.use("/api/*", rateLimitMiddleware); 63 | ``` 64 | 65 | ### Adding New Handler 66 | 67 | ```typescript 68 | // 1. Create handler file 69 | // src/handlers/webhook.ts 70 | export const webhookHandler = /* ... */; 71 | 72 | // 2. Export and use 73 | import { webhookHandler } from "./handlers/webhook"; 74 | ``` 75 | 76 | ## 🔧 Middleware Execution Order 77 | 78 | 1. **Error Handler** - Global error catching 79 | 2. **Logger** - Request logging 80 | 3. **Session** - Authentication (API/RPC routes only) 81 | 4. **CORS** - Cross-origin configuration 82 | 5. **Auth** - Better Auth endpoints 83 | 6. **API/RPC Handlers** - Business logic 84 | 85 | ## 📝 Notes 86 | 87 | - All middleware is now testable in isolation 88 | - Barrel files (`index.ts`) provide clean import paths 89 | - Each module has a single responsibility 90 | - Easy to add/remove features without touching main file 91 | -------------------------------------------------------------------------------- /apps/native/components/sign-in.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | ActivityIndicator, 4 | KeyboardAvoidingView, 5 | Platform, 6 | Text, 7 | TextInput, 8 | TouchableOpacity, 9 | View, 10 | } from "react-native"; 11 | import { authClient } from "@/lib/auth-client"; 12 | import { queryClient } from "@/utils/orpc"; 13 | 14 | export function SignIn() { 15 | const [email, setEmail] = useState(""); 16 | const [password, setPassword] = useState(""); 17 | const [isLoading, setIsLoading] = useState(false); 18 | const [error, setError] = useState(null); 19 | 20 | const handleLogin = async () => { 21 | setIsLoading(true); 22 | setError(null); 23 | 24 | await authClient.signIn.email( 25 | { 26 | email, 27 | password, 28 | }, 29 | { 30 | onError: (error) => { 31 | setError(error.error?.message || "Failed to sign in"); 32 | setIsLoading(false); 33 | }, 34 | onSuccess: () => { 35 | setEmail(""); 36 | setPassword(""); 37 | queryClient.refetchQueries(); 38 | }, 39 | onFinished: () => { 40 | setIsLoading(false); 41 | }, 42 | } 43 | ); 44 | }; 45 | 46 | return ( 47 | 51 | 52 | Sign In 53 | 54 | 55 | {error && ( 56 | 57 | {error} 58 | 59 | )} 60 | 61 | 70 | 71 | 79 | 80 | 85 | {isLoading ? ( 86 | 87 | ) : ( 88 | Sign In 89 | )} 90 | 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /apps/web/src/components/nav-documents.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | type Icon, 3 | IconDots, 4 | IconFolder, 5 | IconShare3, 6 | IconTrash, 7 | } from "@tabler/icons-react"; 8 | 9 | import { 10 | DropdownMenu, 11 | DropdownMenuContent, 12 | DropdownMenuItem, 13 | DropdownMenuSeparator, 14 | DropdownMenuTrigger, 15 | } from "@/components/ui/dropdown-menu"; 16 | import { 17 | SidebarGroup, 18 | SidebarGroupLabel, 19 | SidebarMenu, 20 | SidebarMenuAction, 21 | SidebarMenuButton, 22 | SidebarMenuItem, 23 | useSidebar, 24 | } from "@/components/ui/sidebar"; 25 | 26 | export function NavDocuments({ 27 | items, 28 | }: { 29 | items: { 30 | name: string; 31 | url: string; 32 | icon: Icon; 33 | }[]; 34 | }) { 35 | const { isMobile } = useSidebar(); 36 | 37 | return ( 38 | 39 | Documents 40 | 41 | {items.map((item) => ( 42 | 43 | 44 | 45 | 46 | {item.name} 47 | 48 | 49 | 50 | 51 | 55 | 56 | More 57 | 58 | 59 | 64 | 65 | 66 | Open 67 | 68 | 69 | 70 | Share 71 | 72 | 73 | 74 | 75 | Delete 76 | 77 | 78 | 79 | 80 | ))} 81 | 82 | 83 | 84 | More 85 | 86 | 87 | 88 | 89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/breadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { Slot } from "@radix-ui/react-slot"; 2 | import { ChevronRight, MoreHorizontal } from "lucide-react"; 3 | import type * as React from "react"; 4 | 5 | import { cn } from "@/lib/utils"; 6 | 7 | function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { 8 | return