├── .gitignore ├── apps ├── web │ ├── public │ │ ├── _redirects │ │ ├── logo.png │ │ ├── _routes.json │ │ ├── manifest.json │ │ └── sw.js │ ├── .env.example │ ├── src │ │ ├── lib │ │ │ ├── utils.ts │ │ │ └── auth-client.ts │ │ ├── components │ │ │ ├── loader.tsx │ │ │ ├── ui │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── sonner.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── checkbox.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── card.tsx │ │ │ │ └── dropdown-menu.tsx │ │ │ ├── offline-indicator.tsx │ │ │ ├── mode-toggle.tsx │ │ │ ├── typing-indicator.tsx │ │ │ ├── theme-provider.tsx │ │ │ ├── sign-in-form.tsx │ │ │ ├── user-menu.tsx │ │ │ ├── sign-up-form.tsx │ │ │ ├── pwa-install-prompt.tsx │ │ │ ├── header.tsx │ │ │ └── profile-picture-upload.tsx │ │ ├── routes │ │ │ ├── login.tsx │ │ │ ├── dashboard.tsx │ │ │ ├── __root.tsx │ │ │ ├── public-chat.tsx │ │ │ ├── profile.tsx │ │ │ ├── todos.tsx │ │ │ └── install-pwa.tsx │ │ ├── utils │ │ │ ├── orpc.ts │ │ │ └── imageCompression.ts │ │ ├── main.tsx │ │ ├── hooks │ │ │ ├── useTodoMutations.ts │ │ │ ├── useImageHandling.ts │ │ │ └── useOfflineSync.ts │ │ ├── index.css │ │ └── routeTree.gen.ts │ ├── pwa-assets.config.ts │ ├── components.json │ ├── tsconfig.json │ ├── .gitignore │ ├── vite.config.ts │ ├── index.html │ └── package.json └── server │ ├── drizzle.config.ts │ ├── src │ ├── db │ │ ├── schema │ │ │ ├── todo.ts │ │ │ ├── admin_chat_messages.ts │ │ │ ├── public_chat_messages.ts │ │ │ └── auth.ts │ │ ├── migrations │ │ │ ├── 0001_safe_nekra.sql │ │ │ ├── meta │ │ │ │ └── _journal.json │ │ │ └── 0000_right_epoch.sql │ │ └── index.ts │ ├── lib │ │ ├── orpc.ts │ │ ├── context.ts │ │ ├── auth.ts │ │ ├── db-factory.ts │ │ ├── broadcast.ts │ │ └── r2.ts │ ├── routers │ │ ├── index.ts │ │ ├── admin-chat.ts │ │ ├── public-chat.ts │ │ ├── profile.ts │ │ └── todo.ts │ └── types │ │ └── global.ts │ ├── tsconfig.json │ ├── .gitignore │ ├── .env.example │ ├── wrangler.jsonc │ ├── package.json │ └── DEVELOPMENT.md ├── bun.lockb ├── screenshots ├── home.png ├── todos.png ├── admin chat.png ├── dashboard.png └── offline todos.png ├── .claude └── settings.local.json ├── bts.jsonc ├── package.json ├── turbo.json ├── scripts └── deploy-local.sh ├── .github └── workflows │ └── deploy.yml ├── DEPLOYMENT.md ├── README.md ├── test-health.js ├── SETUP.md └── DEPLOY.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .turbo 3 | -------------------------------------------------------------------------------- /apps/web/public/_redirects: -------------------------------------------------------------------------------- 1 | /* /index.html 200 -------------------------------------------------------------------------------- /apps/web/.env.example: -------------------------------------------------------------------------------- 1 | VITE_SERVER_URL=http://localhost:3000 -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/bun.lockb -------------------------------------------------------------------------------- /screenshots/home.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/screenshots/home.png -------------------------------------------------------------------------------- /screenshots/todos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/screenshots/todos.png -------------------------------------------------------------------------------- /apps/web/public/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/apps/web/public/logo.png -------------------------------------------------------------------------------- /screenshots/admin chat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/screenshots/admin chat.png -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/offline todos.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DiogoDuart3/future-stack/HEAD/screenshots/offline todos.png -------------------------------------------------------------------------------- /apps/web/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /apps/web/public/_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "include": ["/*"], 4 | "exclude": [ 5 | "/api/*", 6 | "/_assets/*", 7 | "/_worker.js", 8 | "/sw.js", 9 | "/manifest.json" 10 | ] 11 | } -------------------------------------------------------------------------------- /apps/web/src/lib/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 | basePath: "/auth", 6 | }); 7 | -------------------------------------------------------------------------------- /.claude/settings.local.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": { 3 | "allow": [ 4 | "Bash(bun add:*)", 5 | "Bash(wrangler deploy:*)", 6 | "Bash(npm run check-types:*)" 7 | ], 8 | "deny": [] 9 | } 10 | } -------------------------------------------------------------------------------- /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/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 | }); -------------------------------------------------------------------------------- /apps/web/pwa-assets.config.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineConfig, 3 | minimal2023Preset as preset, 4 | } from "@vite-pwa/assets-generator/config"; 5 | 6 | export default defineConfig({ 7 | headLinkOptions: { 8 | preset: "2023", 9 | }, 10 | preset, 11 | images: ["public/logo.png"], 12 | }); 13 | -------------------------------------------------------------------------------- /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/server/src/db/schema/todo.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, boolean, serial, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const todo = pgTable("todo", { 4 | id: serial("id").primaryKey(), 5 | text: text("text").notNull(), 6 | completed: boolean("completed").default(false).notNull(), 7 | imageUrl: text("image_url"), 8 | createdAt: timestamp("created_at").defaultNow().notNull(), 9 | }); 10 | -------------------------------------------------------------------------------- /apps/server/src/db/migrations/0001_safe_nekra.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE "public_chat_messages" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "message" text NOT NULL, 4 | "user_id" text NOT NULL, 5 | "user_name" text NOT NULL, 6 | "user_email" text NOT NULL, 7 | "user_profile_picture" text, 8 | "created_at" timestamp NOT NULL, 9 | "updated_at" timestamp NOT NULL 10 | ); 11 | --> statement-breakpoint 12 | ALTER TABLE "user" ADD COLUMN "profile_picture" text; -------------------------------------------------------------------------------- /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": 1754058135708, 9 | "tag": "0000_right_epoch", 10 | "breakpoints": true 11 | }, 12 | { 13 | "idx": 1, 14 | "version": "7", 15 | "when": 1754127605891, 16 | "tag": "0001_safe_nekra", 17 | "breakpoints": true 18 | } 19 | ] 20 | } -------------------------------------------------------------------------------- /apps/server/src/db/schema/admin_chat_messages.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, boolean, serial, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const adminChatMessages = pgTable("admin_chat_messages", { 4 | id: serial("id").primaryKey(), 5 | message: text("message").notNull(), 6 | userId: text("user_id").notNull(), 7 | userName: text("user_name").notNull(), 8 | userEmail: text("user_email").notNull(), 9 | createdAt: timestamp("created_at").notNull(), 10 | updatedAt: timestamp("updated_at").notNull(), 11 | }); 12 | -------------------------------------------------------------------------------- /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/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(async ({ context, next }) => { 9 | if (!context.session?.user) { 10 | throw new ORPCError("UNAUTHORIZED"); 11 | } 12 | return next({ 13 | context: { 14 | session: context.session, 15 | }, 16 | }); 17 | }); 18 | 19 | export const protectedProcedure = publicProcedure.use(requireAuth); 20 | -------------------------------------------------------------------------------- /apps/web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "esModuleInterop": true, 5 | "jsx": "react-jsx", 6 | "target": "ESNext", 7 | "module": "ESNext", 8 | "moduleResolution": "Bundler", 9 | "verbatimModuleSyntax": true, 10 | "skipLibCheck": true, 11 | "types": ["vite/client"], 12 | "rootDirs": ["."], 13 | "baseUrl": ".", 14 | "paths": { 15 | "@/*": ["./src/*"], 16 | "@server/*": ["../server/src/*"] 17 | } 18 | }, 19 | "references": [{ 20 | "path": "../server" 21 | }] 22 | } 23 | -------------------------------------------------------------------------------- /apps/server/src/db/schema/public_chat_messages.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, boolean, serial, timestamp } from "drizzle-orm/pg-core"; 2 | 3 | export const publicChatMessages = pgTable("public_chat_messages", { 4 | id: serial("id").primaryKey(), 5 | message: text("message").notNull(), 6 | userId: text("user_id").notNull(), 7 | userName: text("user_name").notNull(), 8 | userEmail: text("user_email").notNull(), 9 | userProfilePicture: text("user_profile_picture"), // R2 key for user's profile picture 10 | createdAt: timestamp("created_at").notNull(), 11 | updatedAt: timestamp("updated_at").notNull(), 12 | }); -------------------------------------------------------------------------------- /bts.jsonc: -------------------------------------------------------------------------------- 1 | // Better-T-Stack configuration file 2 | // safe to delete 3 | 4 | { 5 | "$schema": "https://better-t-stack.dev/schema.json", 6 | "version": "2.26.1", 7 | "createdAt": "2025-07-25T20:30:27.425Z", 8 | "database": "postgres", 9 | "orm": "drizzle", 10 | "backend": "hono", 11 | "runtime": "workers", 12 | "frontend": [ 13 | "tanstack-router" 14 | ], 15 | "addons": [ 16 | "pwa", 17 | "turborepo" 18 | ], 19 | "examples": [ 20 | "todo" 21 | ], 22 | "auth": true, 23 | "packageManager": "bun", 24 | "dbSetup": "neon", 25 | "api": "orpc", 26 | "webDeploy": "workers" 27 | } -------------------------------------------------------------------------------- /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": [ 15 | "./worker-configuration", 16 | "node" 17 | ], 18 | "composite": true, 19 | "jsx": "react-jsx", 20 | "jsxImportSource": "hono/jsx" 21 | }, 22 | "tsc-alias": { 23 | "resolveFullPaths": true 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/web/src/routes/login.tsx: -------------------------------------------------------------------------------- 1 | import SignInForm from "@/components/sign-in-form"; 2 | import SignUpForm from "@/components/sign-up-form"; 3 | import { createFileRoute } from "@tanstack/react-router"; 4 | import { useState } from "react"; 5 | 6 | export const Route = createFileRoute("/login")({ 7 | component: RouteComponent, 8 | }); 9 | 10 | function RouteComponent() { 11 | const [showSignIn, setShowSignIn] = useState(true); 12 | 13 | return showSignIn ? ( 14 | setShowSignIn(false)} /> 15 | ) : ( 16 | setShowSignIn(true)} /> 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /apps/server/src/lib/context.ts: -------------------------------------------------------------------------------- 1 | import type { Context as HonoContext } from "hono"; 2 | import type { Env } from "../types/global"; 3 | import { createAuth } from "./auth"; 4 | 5 | export type CreateContextOptions = { 6 | context: HonoContext<{ Bindings: Env }>; 7 | auth: ReturnType; 8 | }; 9 | 10 | export async function createContext({ context, auth }: CreateContextOptions) { 11 | const session = await auth.api.getSession({ 12 | headers: context.req.raw.headers, 13 | }); 14 | return { 15 | session, 16 | env: context.env, 17 | req: context.req, 18 | }; 19 | } 20 | 21 | export type Context = Awaited>; 22 | -------------------------------------------------------------------------------- /apps/web/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Ecomantem - Offline Todos", 3 | "short_name": "Ecomantem", 4 | "description": "Offline-first todo app with image support", 5 | "start_url": "/todos-offline", 6 | "display": "standalone", 7 | "background_color": "#ffffff", 8 | "theme_color": "#000000", 9 | "icons": [ 10 | { 11 | "src": "/logo.png", 12 | "sizes": "192x192", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "/logo.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "categories": ["productivity", "utilities"], 22 | "features": [ 23 | "offline", 24 | "background-sync" 25 | ] 26 | } -------------------------------------------------------------------------------- /apps/web/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Label as LabelPrimitive } from "radix-ui" 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/web/src/components/ui/sonner.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import { useTheme } from "next-themes"; 4 | import { Toaster as Sonner, type ToasterProps } from "sonner"; 5 | 6 | const Toaster = ({ ...props }: ToasterProps) => { 7 | const { theme = "system" } = useTheme(); 8 | 9 | return ( 10 | 22 | ); 23 | }; 24 | 25 | export { Toaster }; 26 | -------------------------------------------------------------------------------- /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 | /.next/ 20 | .vercel 21 | 22 | # deps 23 | node_modules/ 24 | /node_modules 25 | /.pnp 26 | .pnp.* 27 | 28 | # env 29 | .env* 30 | .env.production 31 | !.env.example 32 | .dev.vars 33 | 34 | # logs 35 | logs/ 36 | *.log 37 | npm-debug.log* 38 | yarn-debug.log* 39 | yarn-error.log* 40 | pnpm-debug.log* 41 | lerna-debug.log* 42 | 43 | # misc 44 | .DS_Store 45 | *.pem 46 | 47 | # local db 48 | *.db* 49 | 50 | # typescript 51 | *.tsbuildinfo 52 | next-env.d.ts 53 | -------------------------------------------------------------------------------- /apps/server/src/db/index.ts: -------------------------------------------------------------------------------- 1 | 2 | import { neon } from '@neondatabase/serverless'; 3 | import { drizzle } from 'drizzle-orm/neon-http'; 4 | import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js'; 5 | import postgres from 'postgres'; 6 | import { env } from "cloudflare:workers"; 7 | 8 | // Check if we're in development mode 9 | const isDevelopment = (env.NODE_ENV as string) === 'development'; 10 | 11 | let db; 12 | 13 | if (isDevelopment) { 14 | // Use local PostgreSQL for development 15 | const sql = postgres(env.DATABASE_URL || "postgresql://postgres:password@localhost:5432/ecomantem"); 16 | db = drizzlePostgres(sql); 17 | } else { 18 | // Use Neon for production 19 | const sql = neon(env.DATABASE_URL || ""); 20 | db = drizzle(sql); 21 | } 22 | 23 | export { db }; 24 | -------------------------------------------------------------------------------- /apps/server/src/routers/index.ts: -------------------------------------------------------------------------------- 1 | import { protectedProcedure, publicProcedure, o } from "../lib/orpc"; 2 | import { todoRouter } from "./todo"; 3 | import { adminChatRouter } from "./admin-chat"; 4 | import { profileRouter } from "./profile"; 5 | import { publicChatRouter } from "./public-chat"; 6 | 7 | export const appRouter = o.router({ 8 | healthCheck: publicProcedure.handler(() => { 9 | return "OK"; 10 | }), 11 | privateData: protectedProcedure.handler(({ context }) => { 12 | return { 13 | message: "This is private", 14 | user: context.session?.user, 15 | }; 16 | }), 17 | todo: todoRouter, 18 | adminChat: adminChatRouter, 19 | profile: profileRouter, 20 | // publicChat: publicChatRouter, 21 | }); 22 | 23 | export type AppRouter = typeof appRouter; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "future-stack", 3 | "private": true, 4 | "workspaces": [ 5 | "apps/*", 6 | "packages/*" 7 | ], 8 | "scripts": { 9 | "dev": "turbo dev", 10 | "build": "turbo build", 11 | "build:types": "turbo build:types", 12 | "check-types": "turbo check-types", 13 | "dev:native": "turbo -F native dev", 14 | "dev:web": "turbo -F web dev", 15 | "dev:server": "turbo -F server dev", 16 | "db:push": "turbo -F server db:push", 17 | "db:studio": "turbo -F server db:studio", 18 | "db:generate": "turbo -F server db:generate", 19 | "db:migrate": "turbo -F server db:migrate", 20 | "deploy:local": "./scripts/deploy-local.sh" 21 | }, 22 | "devDependencies": { 23 | "turbo": "^2.5.5" 24 | }, 25 | "packageManager": "bun@1.1.34" 26 | } 27 | -------------------------------------------------------------------------------- /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 | 30 | # Environment & local files 31 | .env* 32 | !.env.example 33 | .DS_Store 34 | *.pem 35 | *.local 36 | 37 | # Logs 38 | npm-debug.log* 39 | yarn-debug.log* 40 | yarn-error.log* 41 | .pnpm-debug.log* 42 | *.log* 43 | 44 | # TypeScript 45 | *.tsbuildinfo 46 | next-env.d.ts 47 | 48 | # IDE 49 | .vscode/* 50 | !.vscode/extensions.json 51 | .idea 52 | 53 | # Other 54 | dev-dist 55 | 56 | .wrangler 57 | .dev.vars* 58 | 59 | .open-next 60 | -------------------------------------------------------------------------------- /apps/server/.env.example: -------------------------------------------------------------------------------- 1 | # Database Configuration 2 | # Get from Neon.tech or any PostgreSQL provider 3 | DATABASE_URL=postgresql://user:password@host:port/database?sslmode=require 4 | 5 | # CORS Configuration 6 | # Frontend URL for CORS (e.g., http://localhost:3001 for dev, https://yourdomain.com for prod) 7 | CORS_ORIGIN=http://localhost:3001 8 | 9 | # Better Auth Configuration 10 | # Generate a secure random string for production 11 | BETTER_AUTH_SECRET=your-secure-random-secret-here 12 | # Base URL where your API is running 13 | BETTER_AUTH_URL=http://localhost:3000 14 | 15 | # Cloudflare R2 Configuration (for image uploads) 16 | # Get these from Cloudflare R2 dashboard 17 | CLOUDFLARE_ACCOUNT_ID=your-cloudflare-account-id 18 | R2_ACCESS_KEY_ID=your-r2-access-key-id 19 | R2_SECRET_ACCESS_KEY=your-r2-secret-access-key 20 | 21 | # Node Environment 22 | NODE_ENV=development -------------------------------------------------------------------------------- /apps/web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { VitePWA } from 'vite-plugin-pwa'; 2 | import tailwindcss from "@tailwindcss/vite"; 3 | import { tanstackRouter } from "@tanstack/router-plugin/vite"; 4 | import react from "@vitejs/plugin-react"; 5 | import path from "node:path"; 6 | import { defineConfig } from "vite"; 7 | 8 | export default defineConfig({ 9 | plugins: [ 10 | tailwindcss(), 11 | tanstackRouter({}), 12 | react(), 13 | VitePWA({ 14 | registerType: "autoUpdate", 15 | manifest: { 16 | name: "ecomantem", 17 | short_name: "ecomantem", 18 | description: "ecomantem - PWA Application", 19 | theme_color: "#0c0c0c", 20 | }, 21 | pwaAssets: { disabled: false, config: true }, 22 | devOptions: { enabled: true }, 23 | }), 24 | ], 25 | resolve: { 26 | alias: { 27 | "@": path.resolve(__dirname, "./src"), 28 | }, 29 | }, 30 | }); -------------------------------------------------------------------------------- /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 | "build:types": { 11 | "dependsOn": ["^build:types"], 12 | "inputs": ["$TURBO_DEFAULT$", ".env*"], 13 | "outputs": ["dist/**"] 14 | }, 15 | "lint": { 16 | "dependsOn": ["^lint"] 17 | }, 18 | "check-types": { 19 | "dependsOn": ["^build:types", "^check-types"] 20 | }, 21 | "dev": { 22 | "cache": false, 23 | "persistent": true 24 | }, 25 | "db:push": { 26 | "cache": false, 27 | "persistent": true 28 | }, 29 | "db:studio": { 30 | "cache": false, 31 | "persistent": true 32 | }, 33 | "db:migrate": { 34 | "cache": false, 35 | "persistent": true 36 | }, 37 | "db:generate": { 38 | "cache": false, 39 | "persistent": true 40 | } 41 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /apps/server/src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { betterAuth } from "better-auth"; 2 | import { drizzleAdapter } from "better-auth/adapters/drizzle"; 3 | import * as schema from "../db/schema/auth"; 4 | import { env } from "cloudflare:workers"; 5 | import { createDatabaseConnection } from "./db-factory"; 6 | 7 | // Create a function that returns the auth configuration with a fresh database connection 8 | export function createAuth() { 9 | try { 10 | const db = createDatabaseConnection(); 11 | 12 | return betterAuth({ 13 | database: drizzleAdapter(db, { 14 | provider: "pg", 15 | schema: schema, 16 | }), 17 | trustedOrigins: [env.CORS_ORIGIN], 18 | emailAndPassword: { 19 | enabled: true, 20 | }, 21 | secret: env.BETTER_AUTH_SECRET, 22 | baseURL: env.BETTER_AUTH_URL, 23 | basePath: "/auth", 24 | }); 25 | } catch (error) { 26 | console.error("Failed to create auth configuration:", error); 27 | throw error; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /apps/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Ecomantem - Offline Todos 10 | 11 | 12 | 13 |
14 | 15 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * 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 | -------------------------------------------------------------------------------- /apps/web/src/routes/dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { authClient } from "@/lib/auth-client"; 2 | import { orpc } from "@/utils/orpc"; 3 | import { useQuery } from "@tanstack/react-query"; 4 | import { createFileRoute } from "@tanstack/react-router"; 5 | import { useEffect } from "react"; 6 | 7 | export const Route = createFileRoute("/dashboard")({ 8 | component: RouteComponent, 9 | }); 10 | 11 | function RouteComponent() { 12 | const { data: session, isPending } = authClient.useSession(); 13 | 14 | const navigate = Route.useNavigate(); 15 | 16 | const privateData = useQuery(orpc.privateData.queryOptions()); 17 | 18 | useEffect(() => { 19 | if (!session && !isPending) { 20 | navigate({ 21 | to: "/login", 22 | }); 23 | } 24 | }, [session, isPending]); 25 | 26 | if (isPending) { 27 | return
Loading...
; 28 | } 29 | 30 | return ( 31 |
32 |

Dashboard

33 |

Welcome {session?.user.name}

34 |

privateData: {(privateData.data as { message: string })?.message}

35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/web/src/components/offline-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import { Wifi, WifiOff } from 'lucide-react'; 3 | 4 | export function OfflineIndicator() { 5 | const [isOnline, setIsOnline] = useState(navigator.onLine); 6 | 7 | useEffect(() => { 8 | const handleOnline = () => setIsOnline(true); 9 | const handleOffline = () => setIsOnline(false); 10 | 11 | window.addEventListener('online', handleOnline); 12 | window.addEventListener('offline', handleOffline); 13 | 14 | return () => { 15 | window.removeEventListener('online', handleOnline); 16 | window.removeEventListener('offline', handleOffline); 17 | }; 18 | }, []); 19 | 20 | if (isOnline) return null; 21 | 22 | return ( 23 |
24 |
25 | 26 | You're offline. Changes will sync when you're back online. 27 |
28 |
29 | ); 30 | } -------------------------------------------------------------------------------- /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 { appRouter } from "@server/routers"; 7 | import type { RouterClient } from "@orpc/server"; 8 | 9 | export const queryClient = new QueryClient({ 10 | queryCache: new QueryCache({ 11 | onError: (error) => { 12 | toast.error(`Error: ${error.message}`, { 13 | action: { 14 | label: "retry", 15 | onClick: () => { 16 | queryClient.invalidateQueries(); 17 | }, 18 | }, 19 | }); 20 | }, 21 | }), 22 | }); 23 | 24 | export const link = new RPCLink({ 25 | url: `${import.meta.env.VITE_SERVER_URL}/rpc`, 26 | fetch(url, options) { 27 | return fetch(url, { 28 | ...options, 29 | credentials: "include", 30 | }); 31 | }, 32 | }); 33 | 34 | export const client: RouterClient = createORPCClient(link); 35 | 36 | export const orpc = createTanstackQueryUtils(client); 37 | -------------------------------------------------------------------------------- /apps/server/wrangler.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ecomantem-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://ecomantem.diogodev.com" 9 | // Add public environment variables here 10 | // Example: "CORS_ORIGIN": "https://your-domain.com", 11 | }, 12 | // For sensitive data, use: 13 | // wrangler secret put SECRET_NAME 14 | // Don't add secrets to "vars" - they're visible in the dashboard! 15 | "r2_buckets": [ 16 | { 17 | "binding": "TODO_IMAGES", 18 | "bucket_name": "ecomantem-todo-images" 19 | } 20 | ], 21 | "durable_objects": { 22 | "bindings": [ 23 | { 24 | "name": "ADMIN_CHAT", 25 | "class_name": "AdminChat" 26 | }, 27 | { 28 | "name": "PUBLIC_CHAT", 29 | "class_name": "PublicChat" 30 | } 31 | ] 32 | }, 33 | "migrations": [ 34 | { 35 | "tag": "v1", 36 | "new_sqlite_classes": ["AdminChat"] 37 | }, { 38 | "tag": "v2", 39 | "new_sqlite_classes": ["PublicChat"] 40 | } 41 | ], 42 | "observability": { 43 | "enabled": true 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /apps/server/src/lib/db-factory.ts: -------------------------------------------------------------------------------- 1 | import { neon } from '@neondatabase/serverless'; 2 | import { drizzle } from 'drizzle-orm/neon-http'; 3 | import { drizzle as drizzlePostgres } from 'drizzle-orm/postgres-js'; 4 | import postgres from 'postgres'; 5 | import { env } from "cloudflare:workers"; 6 | 7 | export function createDatabaseConnection() { 8 | const isDevelopment = (env.NODE_ENV as string) === 'development'; 9 | 10 | if (isDevelopment) { 11 | // Use local PostgreSQL for development with optimized settings 12 | const sql = postgres(env.DATABASE_URL || "postgresql://postgres:password@localhost:5432/ecomantem", { 13 | max: 1, // Limit connections to prevent CPU time limit 14 | idle_timeout: 20, // Close idle connections after 20 seconds 15 | connect_timeout: 10, // Connection timeout of 10 seconds 16 | }); 17 | return drizzlePostgres(sql); 18 | } else { 19 | // Use Neon for production with optimized settings 20 | if (!env.DATABASE_URL || env.DATABASE_URL === "") { 21 | throw new Error("DATABASE_URL environment variable is required for production. Please set it in your Cloudflare Workers environment variables."); 22 | } 23 | const sql = neon(env.DATABASE_URL); 24 | return drizzle(sql); 25 | } 26 | } -------------------------------------------------------------------------------- /apps/web/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } -------------------------------------------------------------------------------- /apps/web/src/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Checkbox as CheckboxPrimitive } from "radix-ui" 3 | import { CheckIcon } from "lucide-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/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { Moon, Sun } from "lucide-react"; 2 | 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | DropdownMenu, 6 | DropdownMenuContent, 7 | DropdownMenuItem, 8 | DropdownMenuTrigger, 9 | } from "@/components/ui/dropdown-menu"; 10 | import { useTheme } from "@/components/theme-provider"; 11 | 12 | export function ModeToggle() { 13 | const { setTheme } = useTheme(); 14 | 15 | return ( 16 | 17 | 18 | 23 | 24 | 25 | setTheme("light")}> 26 | Light 27 | 28 | setTheme("dark")}> 29 | Dark 30 | 31 | setTheme("system")}> 32 | System 33 | 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /apps/server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "main": "src/index.ts", 4 | "type": "module", 5 | "scripts": { 6 | "build": "wrangler deploy --dry-run", 7 | "check-types": "tsc --noEmit --skipLibCheck", 8 | "build:types": "tsc --declaration --emitDeclarationOnly --outDir dist", 9 | "compile": "bun build --compile --minify --sourcemap --bytecode ./src/index.ts --outfile server", 10 | "dev": "wrangler dev --port=3008", 11 | "start": "wrangler dev", 12 | "deploy": "wrangler deploy", 13 | "deploy:with-migrations": "bun run db:migrate && wrangler deploy", 14 | "cf-typegen": "wrangler types --env-interface CloudflareBindings", 15 | "db:push": "drizzle-kit push", 16 | "db:studio": "drizzle-kit studio", 17 | "db:generate": "drizzle-kit generate", 18 | "db:migrate": "drizzle-kit migrate" 19 | }, 20 | "dependencies": { 21 | "@aws-sdk/client-s3": "^3.850.0", 22 | "@aws-sdk/s3-request-presigner": "^3.850.0", 23 | "@neondatabase/serverless": "^1.0.1", 24 | "@orpc/client": "^1.5.0", 25 | "@orpc/server": "^1.5.0", 26 | "better-auth": "^1.3.4", 27 | "dotenv": "^16.4.7", 28 | "drizzle-orm": "^0.44.2", 29 | "hono": "^4.8.2", 30 | "postgres": "^3.4.7", 31 | "zod": "^4.0.2" 32 | }, 33 | "devDependencies": { 34 | "tsdown": "^0.12.9", 35 | "typescript": "^5.8.2", 36 | "drizzle-kit": "^0.31.2", 37 | "wrangler": "^4.23.0" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /apps/server/src/types/global.ts: -------------------------------------------------------------------------------- 1 | // Global type definitions for the entire API 2 | 3 | export interface Env { 4 | ADMIN_CHAT: DurableObjectNamespace; 5 | PUBLIC_CHAT: DurableObjectNamespace; 6 | TODO_IMAGES: R2Bucket; 7 | CORS_ORIGIN: string; 8 | BETTER_AUTH_SECRET: string; 9 | BETTER_AUTH_URL: string; 10 | DATABASE_URL: string; 11 | CLOUDFLARE_ACCOUNT_ID: string; 12 | R2_ACCESS_KEY_ID: string; 13 | R2_SECRET_ACCESS_KEY: string; 14 | NODE_ENV: string; 15 | } 16 | 17 | // Chat message interface 18 | export interface ChatMessage { 19 | id: string; 20 | userId: string; 21 | userName: string; 22 | message: string; 23 | timestamp: number; 24 | userProfilePicture?: string; 25 | } 26 | 27 | // WebSocket with user authentication 28 | export interface AuthenticatedWebSocket extends WebSocket { 29 | userId?: string; 30 | userName?: string; 31 | userEmail?: string; 32 | userProfilePicture?: string; 33 | } 34 | 35 | // User info for WebSocket connections 36 | export interface UserInfo { 37 | userId: string; 38 | userName: string; 39 | userEmail: string | null; 40 | userProfilePicture?: string; 41 | isGuest?: boolean; 42 | } 43 | 44 | // Broadcast message request 45 | export interface BroadcastMessageRequest { 46 | message: string; 47 | } 48 | 49 | // Broadcast message response 50 | export interface BroadcastMessageResponse { 51 | success: boolean; 52 | message: string; 53 | } 54 | 55 | // Error response 56 | export interface ErrorResponse { 57 | error: string; 58 | } -------------------------------------------------------------------------------- /scripts/deploy-local.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Local deployment script for testing 4 | # This script mimics the GitHub Actions deployment process locally 5 | 6 | set -e 7 | 8 | echo "🚀 Starting local deployment test..." 9 | 10 | # Check if required environment variables are set 11 | if [ -z "$CLOUDFLARE_API_TOKEN" ]; then 12 | echo "❌ Error: CLOUDFLARE_API_TOKEN is not set" 13 | echo "Please set it with: export CLOUDFLARE_API_TOKEN=your_token" 14 | exit 1 15 | fi 16 | 17 | if [ -z "$DATABASE_URL" ]; then 18 | echo "❌ Error: DATABASE_URL is not set" 19 | echo "Please set it with: export DATABASE_URL=your_database_url" 20 | exit 1 21 | fi 22 | 23 | echo "✅ Environment variables are set" 24 | 25 | # Install dependencies 26 | echo "📦 Installing dependencies..." 27 | bun install 28 | 29 | # Check types 30 | echo "🔍 Checking types..." 31 | bun run check-types 32 | 33 | # Build applications 34 | echo "🏗️ Building applications..." 35 | bun run build 36 | 37 | # Deploy server 38 | echo "🔧 Deploying server to Cloudflare Workers..." 39 | cd apps/server 40 | bun run deploy 41 | 42 | # Run database migrations 43 | echo "🗄️ Running database migrations..." 44 | bun run db:migrate 45 | 46 | # Deploy web app 47 | echo "🌐 Deploying web app to Cloudflare Pages..." 48 | cd ../web 49 | bun run deploy 50 | 51 | echo "✅ Local deployment test completed successfully!" 52 | echo "🌐 Web app deployed to Cloudflare Pages" 53 | echo "🔧 Server deployed to Cloudflare Workers" 54 | echo "🗄️ Database migrations applied" -------------------------------------------------------------------------------- /apps/server/DEVELOPMENT.md: -------------------------------------------------------------------------------- 1 | # Local Development Setup 2 | 3 | ## Database Setup 4 | 5 | ### 1. Install PostgreSQL Locally 6 | 7 | **macOS (using Homebrew):** 8 | ```bash 9 | brew install postgresql 10 | brew services start postgresql 11 | ``` 12 | 13 | **macOS (using Postgres.app):** 14 | - Download from https://postgresapp.com/ 15 | - Install and start the app 16 | 17 | ### 2. Create Database 18 | 19 | ```bash 20 | # Connect to PostgreSQL 21 | psql postgres 22 | 23 | # Create database 24 | CREATE DATABASE ecomantem; 25 | 26 | # Create user (optional) 27 | CREATE USER ecomantem WITH PASSWORD 'password'; 28 | GRANT ALL PRIVILEGES ON DATABASE ecomantem TO ecomantem; 29 | 30 | # Exit psql 31 | \q 32 | ``` 33 | 34 | ### 3. Environment Variables 35 | 36 | Create a `.env` file in the `apps/server` directory: 37 | 38 | ```env 39 | NODE_ENV=development 40 | DATABASE_URL=postgresql://postgres:password@localhost:5432/ecomantem 41 | ``` 42 | 43 | ### 4. Run Migrations 44 | 45 | ```bash 46 | cd apps/server 47 | bun run db:generate 48 | bun run db:push 49 | ``` 50 | 51 | ### 5. Start Development Server 52 | 53 | ```bash 54 | bun run dev 55 | ``` 56 | 57 | ## Database Management 58 | 59 | - **View data:** `bun run db:studio` 60 | - **Generate migrations:** `bun run db:generate` 61 | - **Apply migrations:** `bun run db:push` 62 | 63 | ## Switching Between Local and Cloud 64 | 65 | The database configuration automatically switches based on `NODE_ENV`: 66 | - `NODE_ENV=development` → Uses local PostgreSQL 67 | - `NODE_ENV=production` → Uses Neon (cloud) -------------------------------------------------------------------------------- /apps/web/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { RouterProvider, createRouter } from "@tanstack/react-router"; 2 | import ReactDOM from "react-dom/client"; 3 | import Loader from "./components/loader"; 4 | import { routeTree } from "./routeTree.gen"; 5 | 6 | import { QueryClientProvider } from "@tanstack/react-query"; 7 | import { orpc, queryClient } from "./utils/orpc"; 8 | 9 | // Register service worker for offline support 10 | if ('serviceWorker' in navigator) { 11 | window.addEventListener('load', () => { 12 | navigator.serviceWorker.register('/sw.js') 13 | .then((registration) => { 14 | console.log('SW registered: ', registration); 15 | }) 16 | .catch((registrationError) => { 17 | console.log('SW registration failed: ', registrationError); 18 | }); 19 | }); 20 | } 21 | 22 | const router = createRouter({ 23 | routeTree, 24 | defaultPreload: "intent", 25 | defaultPendingComponent: () => , 26 | context: { orpc, queryClient }, 27 | Wrap: function WrapComponent({ children }: { children: React.ReactNode }) { 28 | return ( 29 | 30 | {children} 31 | 32 | ); 33 | }, 34 | }); 35 | 36 | declare module "@tanstack/react-router" { 37 | interface Register { 38 | router: typeof router; 39 | } 40 | } 41 | 42 | const rootElement = document.getElementById("app"); 43 | 44 | if (!rootElement) { 45 | throw new Error("Root element not found"); 46 | } 47 | 48 | if (!rootElement.innerHTML) { 49 | const root = ReactDOM.createRoot(rootElement); 50 | root.render(); 51 | } 52 | -------------------------------------------------------------------------------- /apps/web/src/components/typing-indicator.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | 3 | interface TypingIndicatorProps { 4 | isTyping: boolean; 5 | userName?: string; 6 | } 7 | 8 | export function TypingIndicator({ isTyping, userName }: TypingIndicatorProps) { 9 | const [dots, setDots] = useState(0); 10 | 11 | useEffect(() => { 12 | if (!isTyping) return; 13 | 14 | const interval = setInterval(() => { 15 | setDots((prev) => (prev + 1) % 4); 16 | }, 500); 17 | 18 | return () => clearInterval(interval); 19 | }, [isTyping]); 20 | 21 | if (!isTyping) return null; 22 | 23 | return ( 24 |
25 |
26 |
27 |
= 1 ? "opacity-100" : "opacity-30" 30 | }`} 31 | /> 32 |
= 2 ? "opacity-100" : "opacity-30" 35 | }`} 36 | /> 37 |
= 3 ? "opacity-100" : "opacity-30" 40 | }`} 41 | /> 42 |
43 |
44 | 45 | {userName ? `${userName} is typing` : "Someone is typing"} 46 | 47 |
48 | ); 49 | } -------------------------------------------------------------------------------- /apps/server/src/routers/admin-chat.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import z from "zod"; 3 | import { user } from "../db/schema/auth"; 4 | import { publicProcedure } from "../lib/orpc"; 5 | import { createDatabaseConnection } from "../lib/db-factory"; 6 | 7 | export const adminChatRouter = { 8 | connect: publicProcedure 9 | .input(z.object({ 10 | userId: z.string(), 11 | })) 12 | .handler(async ({ input, context }) => { 13 | const env = context.env; 14 | const db = createDatabaseConnection(); 15 | 16 | // Verify user is admin 17 | const userRecord = await db.select().from(user).where(eq(user.id, input.userId)).limit(1); 18 | if (!userRecord[0]?.isAdmin) { 19 | throw new Error('Unauthorized: Admin access required'); 20 | } 21 | 22 | // Get Durable Object instance 23 | const id = env.ADMIN_CHAT.idFromName("admin-chat-room"); 24 | const durableObject = env.ADMIN_CHAT.get(id); 25 | 26 | // Create WebSocket connection 27 | const response = await durableObject.fetch(new Request(`${env.BETTER_AUTH_URL}/ws/admin-chat`, { 28 | headers: { 29 | "Upgrade": "websocket", 30 | "x-database-url": env.DATABASE_URL || "", 31 | "x-node-env": env.NODE_ENV || "", 32 | }, 33 | })); 34 | 35 | return response; 36 | }), 37 | 38 | checkAdminStatus: publicProcedure 39 | .input(z.object({ userId: z.string() })) 40 | .handler(async ({ input, context }) => { 41 | const db = createDatabaseConnection(); 42 | const userRecord = await db.select().from(user).where(eq(user.id, input.userId)).limit(1); 43 | return { isAdmin: userRecord[0]?.isAdmin || false }; 44 | }), 45 | }; -------------------------------------------------------------------------------- /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 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )) 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 45 | 46 | export { ScrollArea, ScrollBar } -------------------------------------------------------------------------------- /apps/web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite --port=3001", 8 | "build": "vite build", 9 | "serve": "vite preview", 10 | "start": "vite", 11 | "check-types": "tsc --noEmit", 12 | "generate-pwa-assets": "pwa-assets-generator", 13 | "wrangler:dev": "wrangler dev --port=3001", 14 | "deploy": "bun run build && wrangler pages deploy dist --project-name ecomantem-web" 15 | }, 16 | "devDependencies": { 17 | "@tanstack/react-query-devtools": "^5.80.5", 18 | "@tanstack/react-router-devtools": "^1.114.27", 19 | "@tanstack/router-plugin": "^1.114.27", 20 | "@types/node": "^22.13.13", 21 | "@types/react": "^19.0.12", 22 | "@types/react-dom": "^19.0.4", 23 | "@vite-pwa/assets-generator": "^1.0.0", 24 | "@vitejs/plugin-react": "^4.3.4", 25 | "postcss": "^8.5.3", 26 | "tailwindcss": "^4.0.15", 27 | "vite": "^6.2.2", 28 | "wrangler": "^4.23.0" 29 | }, 30 | "dependencies": { 31 | "@hookform/resolvers": "^5.1.1", 32 | "@orpc/client": "^1.5.0", 33 | "@orpc/server": "^1.5.0", 34 | "@orpc/tanstack-query": "^1.5.0", 35 | "@paralleldrive/cuid2": "^2.2.2", 36 | "@radix-ui/react-scroll-area": "^1.2.9", 37 | "@tailwindcss/vite": "^4.0.15", 38 | "@tanstack/react-form": "^1.12.3", 39 | "@tanstack/react-query": "^5.80.5", 40 | "@tanstack/react-router": "^1.114.25", 41 | "better-auth": "^1.3.4", 42 | "class-variance-authority": "^0.7.1", 43 | "clsx": "^2.1.1", 44 | "lucide-react": "^0.473.0", 45 | "next-themes": "^0.4.6", 46 | "radix-ui": "^1.4.2", 47 | "react": "^19.0.0", 48 | "react-dom": "^19.0.0", 49 | "sonner": "^2.0.5", 50 | "tailwind-merge": "^3.3.1", 51 | "tw-animate-css": "^1.2.5", 52 | "vite-plugin-pwa": "^1.0.1", 53 | "zod": "^4.0.2" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /apps/web/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect, useState } from "react"; 2 | 3 | type Theme = "dark" | "light" | "system"; 4 | 5 | type ThemeProviderProps = { 6 | children: React.ReactNode; 7 | defaultTheme?: Theme; 8 | storageKey?: string; 9 | }; 10 | 11 | type ThemeProviderState = { 12 | theme: Theme; 13 | setTheme: (theme: Theme) => void; 14 | }; 15 | 16 | const initialState: ThemeProviderState = { 17 | theme: "system", 18 | setTheme: () => null, 19 | }; 20 | 21 | const ThemeProviderContext = createContext(initialState); 22 | 23 | export function ThemeProvider({ 24 | children, 25 | defaultTheme = "system", 26 | storageKey = "vite-ui-theme", 27 | ...props 28 | }: ThemeProviderProps) { 29 | const [theme, setTheme] = useState( 30 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 31 | ); 32 | 33 | useEffect(() => { 34 | const root = window.document.documentElement; 35 | 36 | root.classList.remove("light", "dark"); 37 | 38 | if (theme === "system") { 39 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 40 | .matches 41 | ? "dark" 42 | : "light"; 43 | 44 | root.classList.add(systemTheme); 45 | return; 46 | } 47 | 48 | root.classList.add(theme); 49 | }, [theme]); 50 | 51 | const value = { 52 | theme, 53 | setTheme: (theme: Theme) => { 54 | localStorage.setItem(storageKey, theme); 55 | setTheme(theme); 56 | }, 57 | }; 58 | 59 | return ( 60 | 61 | {children} 62 | 63 | ); 64 | } 65 | 66 | export const useTheme = () => { 67 | const context = useContext(ThemeProviderContext); 68 | 69 | if (context === undefined) 70 | throw new Error("useTheme must be used within a ThemeProvider"); 71 | 72 | return context; 73 | }; 74 | -------------------------------------------------------------------------------- /apps/server/src/db/schema/auth.ts: -------------------------------------------------------------------------------- 1 | import { pgTable, text, timestamp, boolean, serial } 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 | profilePicture: text('profile_picture'), // R2 key for uploaded profile picture 10 | isAdmin: boolean('is_admin').default(false).notNull(), 11 | createdAt: timestamp('created_at').notNull(), 12 | updatedAt: timestamp('updated_at').notNull() 13 | }); 14 | 15 | export const session = pgTable("session", { 16 | id: text("id").primaryKey(), 17 | expiresAt: timestamp('expires_at').notNull(), 18 | token: text('token').notNull().unique(), 19 | createdAt: timestamp('created_at').notNull(), 20 | updatedAt: timestamp('updated_at').notNull(), 21 | ipAddress: text('ip_address'), 22 | userAgent: text('user_agent'), 23 | userId: text('user_id').notNull().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').notNull().references(()=> user.id, { onDelete: 'cascade' }), 31 | accessToken: text('access_token'), 32 | refreshToken: text('refresh_token'), 33 | idToken: text('id_token'), 34 | accessTokenExpiresAt: timestamp('access_token_expires_at'), 35 | refreshTokenExpiresAt: timestamp('refresh_token_expires_at'), 36 | scope: text('scope'), 37 | password: text('password'), 38 | createdAt: timestamp('created_at').notNull(), 39 | updatedAt: timestamp('updated_at').notNull() 40 | }); 41 | 42 | export const verification = pgTable("verification", { 43 | id: text("id").primaryKey(), 44 | identifier: text('identifier').notNull(), 45 | value: text('value').notNull(), 46 | expiresAt: timestamp('expires_at').notNull(), 47 | createdAt: timestamp('created_at'), 48 | updatedAt: timestamp('updated_at') 49 | }); 50 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot as SlotPrimitive } from "radix-ui" 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: 13 | "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", 16 | outline: 17 | "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", 20 | ghost: 21 | "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", 22 | link: "text-primary underline-offset-4 hover:underline", 23 | }, 24 | size: { 25 | default: "h-9 px-4 py-2 has-[>svg]:px-3", 26 | sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", 27 | lg: "h-10 rounded-md px-6 has-[>svg]:px-4", 28 | icon: "size-9", 29 | }, 30 | }, 31 | defaultVariants: { 32 | variant: "default", 33 | size: "default", 34 | }, 35 | } 36 | ) 37 | 38 | function Button({ 39 | className, 40 | variant, 41 | size, 42 | asChild = false, 43 | ...props 44 | }: React.ComponentProps<"button"> & 45 | VariantProps & { 46 | asChild?: boolean 47 | }) { 48 | const Comp = asChild ? SlotPrimitive.Slot : "button" 49 | 50 | return ( 51 | 56 | ) 57 | } 58 | 59 | export { Button, buttonVariants } 60 | -------------------------------------------------------------------------------- /apps/web/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * 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 | -------------------------------------------------------------------------------- /apps/web/src/routes/__root.tsx: -------------------------------------------------------------------------------- 1 | import Header from "@/components/header"; 2 | import Loader from "@/components/loader"; 3 | import { OfflineIndicator } from "@/components/offline-indicator"; 4 | import { ThemeProvider } from "@/components/theme-provider"; 5 | import { Toaster } from "@/components/ui/sonner"; 6 | import { link, orpc } from "@/utils/orpc"; 7 | import type { QueryClient } from "@tanstack/react-query"; 8 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 9 | import { useState } from "react"; 10 | import type { RouterClient } from "@orpc/server"; 11 | import { createTanstackQueryUtils } from "@orpc/tanstack-query"; 12 | import type { appRouter } from "@server/routers"; 13 | import { createORPCClient } from "@orpc/client"; 14 | import { 15 | HeadContent, 16 | Outlet, 17 | createRootRouteWithContext, 18 | useRouterState, 19 | } from "@tanstack/react-router"; 20 | import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; 21 | import "../index.css"; 22 | 23 | export interface RouterAppContext { 24 | orpc: typeof orpc; 25 | queryClient: QueryClient; 26 | } 27 | 28 | export const Route = createRootRouteWithContext()({ 29 | component: RootComponent, 30 | head: () => ({ 31 | meta: [ 32 | { 33 | title: "My App", 34 | }, 35 | { 36 | name: "description", 37 | content: "My App is a web application", 38 | }, 39 | ], 40 | links: [ 41 | { 42 | rel: "icon", 43 | href: "/favicon.ico", 44 | }, 45 | ], 46 | }), 47 | }); 48 | 49 | function RootComponent() { 50 | const isFetching = useRouterState({ 51 | select: (s) => s.isLoading, 52 | }); 53 | 54 | const [client] = useState>(() => createORPCClient(link)); 55 | const [orpcUtils] = useState(() => createTanstackQueryUtils(client)); 56 | 57 | return ( 58 | <> 59 | 60 | 61 | 62 |
63 |
64 | {isFetching ? : } 65 |
66 | 67 |
68 | 69 | 70 | 71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useTodoMutations.ts: -------------------------------------------------------------------------------- 1 | import { useMutation } from '@tanstack/react-query'; 2 | import { orpc } from '@/utils/orpc'; 3 | import { compressImage } from '@/utils/imageCompression'; 4 | 5 | export interface CreateTodoInput { 6 | text: string; 7 | imageFile?: File; 8 | } 9 | 10 | export function useTodoMutations() { 11 | const createTodoMutation = useMutation({ 12 | mutationFn: async (input: CreateTodoInput) => { 13 | // First, create the todo with text 14 | const todo = await orpc.todo.create.call({ text: input.text }); 15 | 16 | // If there's an image, compress and upload it 17 | if (input.imageFile) { 18 | console.log('Original image size:', input.imageFile.size, 'bytes'); 19 | 20 | // Compress the image 21 | const compressedFile = await compressImage(input.imageFile); 22 | console.log('Compressed image size:', compressedFile.size, 'bytes'); 23 | console.log('Compression ratio:', ((1 - compressedFile.size / input.imageFile.size) * 100).toFixed(1) + '%'); 24 | 25 | // Convert compressed file to base64 26 | const base64Data = await new Promise((resolve, reject) => { 27 | const reader = new FileReader(); 28 | reader.onload = () => { 29 | const result = reader.result as string; 30 | // Remove data URL prefix to get just the base64 data 31 | const base64 = result.split(',')[1]; 32 | resolve(base64); 33 | }; 34 | reader.onerror = reject; 35 | reader.readAsDataURL(compressedFile); 36 | }); 37 | 38 | // Upload the compressed image 39 | await orpc.todo.uploadImage.call({ 40 | todoId: todo.id, 41 | filename: compressedFile.name, 42 | contentType: compressedFile.type, 43 | fileData: base64Data, 44 | }); 45 | } 46 | 47 | return todo; 48 | }, 49 | }); 50 | 51 | const toggleTodoMutation = useMutation({ 52 | mutationFn: async (input: { id: number; completed: boolean }) => { 53 | return await orpc.todo.toggle.call(input); 54 | }, 55 | }); 56 | 57 | const deleteTodoMutation = useMutation({ 58 | mutationFn: async (input: { id: number }) => { 59 | return await orpc.todo.delete.call(input); 60 | }, 61 | }); 62 | 63 | return { 64 | createTodoMutation, 65 | toggleTodoMutation, 66 | deleteTodoMutation, 67 | }; 68 | } -------------------------------------------------------------------------------- /apps/web/src/utils/imageCompression.ts: -------------------------------------------------------------------------------- 1 | export const compressImage = async (file: File, maxWidth = 800, quality = 0.8): Promise => { 2 | return new Promise((resolve, reject) => { 3 | const canvas = document.createElement('canvas'); 4 | const ctx = canvas.getContext('2d'); 5 | const img = new Image(); 6 | 7 | img.onload = () => { 8 | // Calculate new dimensions while maintaining aspect ratio 9 | const { width, height } = img; 10 | let newWidth = width; 11 | let newHeight = height; 12 | 13 | if (width > maxWidth) { 14 | newWidth = maxWidth; 15 | newHeight = (height * maxWidth) / width; 16 | } 17 | 18 | // Set canvas dimensions 19 | canvas.width = newWidth; 20 | canvas.height = newHeight; 21 | 22 | // Draw and compress image 23 | ctx?.drawImage(img, 0, 0, newWidth, newHeight); 24 | 25 | // Convert to blob with compression 26 | canvas.toBlob( 27 | (blob) => { 28 | if (blob) { 29 | // Create new file with compressed data 30 | const compressedFile = new File([blob], file.name, { 31 | type: file.type, 32 | lastModified: Date.now(), 33 | }); 34 | resolve(compressedFile); 35 | } else { 36 | reject(new Error('Failed to compress image')); 37 | } 38 | }, 39 | file.type, 40 | quality 41 | ); 42 | }; 43 | 44 | img.onerror = () => reject(new Error('Failed to load image')); 45 | img.src = URL.createObjectURL(file); 46 | }); 47 | }; 48 | 49 | export const createImagePreview = async (file: File): Promise => { 50 | try { 51 | const compressedFile = await compressImage(file); 52 | return new Promise((resolve, reject) => { 53 | const reader = new FileReader(); 54 | reader.onload = (e) => { 55 | resolve(e.target?.result as string); 56 | }; 57 | reader.onerror = reject; 58 | reader.readAsDataURL(compressedFile); 59 | }); 60 | } catch (error) { 61 | console.error('Failed to create image preview:', error); 62 | // Fallback to original file 63 | return new Promise((resolve, reject) => { 64 | const reader = new FileReader(); 65 | reader.onload = (e) => { 66 | resolve(e.target?.result as string); 67 | }; 68 | reader.onerror = reject; 69 | reader.readAsDataURL(file); 70 | }); 71 | } 72 | }; -------------------------------------------------------------------------------- /apps/server/src/db/migrations/0000_right_epoch.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS "admin_chat_messages" ( 2 | "id" serial PRIMARY KEY NOT NULL, 3 | "message" text NOT NULL, 4 | "user_id" text NOT NULL, 5 | "user_name" text NOT NULL, 6 | "user_email" text NOT NULL, 7 | "created_at" timestamp NOT NULL, 8 | "updated_at" timestamp NOT NULL 9 | ); 10 | --> statement-breakpoint 11 | CREATE TABLE IF NOT EXISTS "account" ( 12 | "id" text PRIMARY KEY NOT NULL, 13 | "account_id" text NOT NULL, 14 | "provider_id" text NOT NULL, 15 | "user_id" text NOT NULL, 16 | "access_token" text, 17 | "refresh_token" text, 18 | "id_token" text, 19 | "access_token_expires_at" timestamp, 20 | "refresh_token_expires_at" timestamp, 21 | "scope" text, 22 | "password" text, 23 | "created_at" timestamp NOT NULL, 24 | "updated_at" timestamp NOT NULL 25 | ); 26 | --> statement-breakpoint 27 | CREATE TABLE IF NOT EXISTS "session" ( 28 | "id" text PRIMARY KEY NOT NULL, 29 | "expires_at" timestamp NOT NULL, 30 | "token" text NOT NULL, 31 | "created_at" timestamp NOT NULL, 32 | "updated_at" timestamp NOT NULL, 33 | "ip_address" text, 34 | "user_agent" text, 35 | "user_id" text NOT NULL, 36 | CONSTRAINT "session_token_unique" UNIQUE("token") 37 | ); 38 | --> statement-breakpoint 39 | CREATE TABLE IF NOT EXISTS "user" ( 40 | "id" text PRIMARY KEY NOT NULL, 41 | "name" text NOT NULL, 42 | "email" text NOT NULL, 43 | "email_verified" boolean NOT NULL, 44 | "image" text, 45 | "is_admin" boolean DEFAULT false NOT NULL, 46 | "created_at" timestamp NOT NULL, 47 | "updated_at" timestamp NOT NULL, 48 | CONSTRAINT "user_email_unique" UNIQUE("email") 49 | ); 50 | --> statement-breakpoint 51 | CREATE TABLE IF NOT EXISTS "verification" ( 52 | "id" text PRIMARY KEY NOT NULL, 53 | "identifier" text NOT NULL, 54 | "value" text NOT NULL, 55 | "expires_at" timestamp NOT NULL, 56 | "created_at" timestamp, 57 | "updated_at" timestamp 58 | ); 59 | --> statement-breakpoint 60 | CREATE TABLE IF NOT EXISTS "todo" ( 61 | "id" serial PRIMARY KEY NOT NULL, 62 | "text" text NOT NULL, 63 | "completed" boolean DEFAULT false NOT NULL, 64 | "image_url" text, 65 | "created_at" timestamp DEFAULT now() NOT NULL 66 | ); 67 | --> statement-breakpoint 68 | ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint 69 | ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; -------------------------------------------------------------------------------- /apps/web/src/hooks/useImageHandling.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from 'react'; 2 | import { createImagePreview } from '@/utils/imageCompression'; 3 | 4 | // Local validation functions 5 | const validateImageType = (file: File): boolean => { 6 | const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; 7 | return validTypes.includes(file.type); 8 | }; 9 | 10 | const validateFileSize = (file: File): boolean => { 11 | const maxSize = 5 * 1024 * 1024; // 5MB 12 | return file.size <= maxSize; 13 | }; 14 | 15 | export function useImageHandling() { 16 | const [selectedImage, setSelectedImage] = useState(null); 17 | const [imagePreview, setImagePreview] = useState(null); 18 | const fileInputRef = useRef(null); 19 | 20 | const handleImageSelect = async (e: React.ChangeEvent) => { 21 | const file = e.target.files?.[0]; 22 | if (file) { 23 | // Validate file type and size using shared utilities 24 | if (!validateImageType(file)) { 25 | alert('Please select a valid image file (JPEG, PNG, GIF, WebP)'); 26 | return; 27 | } 28 | if (!validateFileSize(file)) { 29 | alert('File too large. Maximum size is 5MB.'); 30 | return; 31 | } 32 | 33 | setSelectedImage(file); 34 | 35 | // Show compressed preview 36 | try { 37 | const preview = await createImagePreview(file); 38 | setImagePreview(preview); 39 | 40 | // Log compression info 41 | console.log('Preview compression:', { 42 | original: file.size, 43 | preview: preview.length, 44 | }); 45 | } catch (error) { 46 | console.error('Failed to create preview:', error); 47 | // Fallback to original file 48 | const reader = new FileReader(); 49 | reader.onload = (e) => { 50 | setImagePreview(e.target?.result as string); 51 | }; 52 | reader.readAsDataURL(file); 53 | } 54 | } 55 | }; 56 | 57 | const handleRemoveImage = () => { 58 | setSelectedImage(null); 59 | setImagePreview(null); 60 | if (fileInputRef.current) { 61 | fileInputRef.current.value = ""; 62 | } 63 | }; 64 | 65 | const clearImage = () => { 66 | setSelectedImage(null); 67 | setImagePreview(null); 68 | if (fileInputRef.current) { 69 | fileInputRef.current.value = ""; 70 | } 71 | }; 72 | 73 | return { 74 | selectedImage, 75 | imagePreview, 76 | fileInputRef, 77 | handleImageSelect, 78 | handleRemoveImage, 79 | clearImage, 80 | }; 81 | } -------------------------------------------------------------------------------- /apps/server/src/lib/broadcast.ts: -------------------------------------------------------------------------------- 1 | import type { Env } from "../types/global"; 2 | 3 | /** 4 | * Broadcast a message to all connected admin chat WebSocket clients 5 | * @param env - The environment containing the ADMIN_CHAT binding 6 | * @param message - The message to broadcast 7 | * @returns Promise that resolves when the message is sent 8 | */ 9 | export async function broadcastToAdminChat(env: Env, message: string): Promise { 10 | try { 11 | // Get the durable object instance 12 | const id = env.ADMIN_CHAT.idFromName("admin-chat-room"); 13 | const durableObject = env.ADMIN_CHAT.get(id); 14 | 15 | // Create a request to the durable object's /send endpoint 16 | const broadcastRequest = new Request(`${env.BETTER_AUTH_URL}/admin-chat/send`, { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | 'x-database-url': env.DATABASE_URL || "", 21 | 'x-node-env': env.NODE_ENV || "", 22 | }, 23 | body: JSON.stringify({ message: message.trim() }), 24 | }); 25 | 26 | // Send the request to the durable object 27 | const response = await durableObject.fetch(broadcastRequest); 28 | 29 | if (!response.ok) { 30 | const errorText = await response.text(); 31 | throw new Error(`Failed to broadcast message: ${errorText}`); 32 | } 33 | } catch (error) { 34 | console.error("Error broadcasting to admin chat:", error); 35 | throw error; 36 | } 37 | } 38 | 39 | /** 40 | * Broadcast a system notification to admin chat 41 | * @param env - The environment containing the ADMIN_CHAT binding 42 | * @param notification - The notification message 43 | * @returns Promise that resolves when the notification is sent 44 | */ 45 | export async function broadcastSystemNotification(env: Env, notification: string): Promise { 46 | const systemMessage = `🔔 System Notification: ${notification}`; 47 | await broadcastToAdminChat(env, systemMessage); 48 | } 49 | 50 | /** 51 | * Broadcast an error notification to admin chat 52 | * @param env - The environment containing the ADMIN_CHAT binding 53 | * @param error - The error message 54 | * @returns Promise that resolves when the error notification is sent 55 | */ 56 | export async function broadcastErrorNotification(env: Env, error: string): Promise { 57 | const errorMessage = `❌ Error: ${error}`; 58 | await broadcastToAdminChat(env, errorMessage); 59 | } 60 | 61 | /** 62 | * Broadcast a success notification to admin chat 63 | * @param env - The environment containing the ADMIN_CHAT binding 64 | * @param message - The success message 65 | * @returns Promise that resolves when the success notification is sent 66 | */ 67 | export async function broadcastSuccessNotification(env: Env, message: string): Promise { 68 | const successMessage = `✅ Success: ${message}`; 69 | await broadcastToAdminChat(env, successMessage); 70 | } -------------------------------------------------------------------------------- /apps/server/src/routers/public-chat.ts: -------------------------------------------------------------------------------- 1 | import { eq, desc } from "drizzle-orm"; 2 | import z from "zod"; 3 | import { user } from "../db/schema/auth"; 4 | import { publicChatMessages } from "../db/schema/public_chat_messages"; 5 | import { publicProcedure } from "../lib/orpc"; 6 | import { createDatabaseConnection } from "../lib/db-factory"; 7 | 8 | export const publicChatRouter = { 9 | connect: publicProcedure 10 | .input(z.object({ 11 | userId: z.string(), 12 | })) 13 | .handler(async ({ input, context }) => { 14 | const env = context.env; 15 | const db = createDatabaseConnection(); 16 | 17 | // Verify user exists and is authorized 18 | const userRecord = await db.select().from(user).where(eq(user.id, input.userId)).limit(1); 19 | if (!userRecord[0]) { 20 | throw new Error('Unauthorized: User not found'); 21 | } 22 | 23 | // Get Durable Object instance 24 | const id = env.PUBLIC_CHAT.idFromName("public-chat-room"); 25 | const durableObject = env.PUBLIC_CHAT.get(id); 26 | 27 | // Create WebSocket connection 28 | const response = await durableObject.fetch(new Request(`${env.BETTER_AUTH_URL}/ws/public-chat`, { 29 | headers: { 30 | "Upgrade": "websocket", 31 | "x-database-url": env.DATABASE_URL || "", 32 | "x-node-env": env.NODE_ENV || "", 33 | }, 34 | })); 35 | 36 | return response; 37 | }), 38 | 39 | getRecentMessages: publicProcedure 40 | .input(z.object({ 41 | limit: z.number().min(1).max(100).default(50) 42 | })) 43 | .handler(async ({ input, context }) => { 44 | const db = createDatabaseConnection(); 45 | const messages = await db 46 | .select({ 47 | id: publicChatMessages.id, 48 | message: publicChatMessages.message, 49 | userId: publicChatMessages.userId, 50 | userName: publicChatMessages.userName, 51 | userEmail: publicChatMessages.userEmail, 52 | userProfilePicture: publicChatMessages.userProfilePicture, 53 | createdAt: publicChatMessages.createdAt, 54 | }) 55 | .from(publicChatMessages) 56 | .orderBy(desc(publicChatMessages.createdAt)) 57 | .limit(input.limit); 58 | 59 | return messages.reverse(); // Return in chronological order 60 | }), 61 | 62 | getUserInfo: publicProcedure 63 | .input(z.object({ userId: z.string() })) 64 | .handler(async ({ input, context }) => { 65 | const db = createDatabaseConnection(); 66 | const userRecord = await db 67 | .select({ 68 | id: user.id, 69 | name: user.name, 70 | email: user.email, 71 | profilePicture: user.profilePicture, 72 | }) 73 | .from(user) 74 | .where(eq(user.id, input.userId)) 75 | .limit(1); 76 | 77 | return userRecord[0] || null; 78 | }), 79 | }; -------------------------------------------------------------------------------- /apps/web/public/sw.js: -------------------------------------------------------------------------------- 1 | // Service Worker for offline support 2 | const CACHE_NAME = 'ecomantem-v1'; 3 | const urlsToCache = [ 4 | '/', 5 | '/todos-offline', 6 | '/static/js/bundle.js', 7 | '/static/css/main.css', 8 | '/manifest.json' 9 | ]; 10 | 11 | // Install event - cache resources 12 | self.addEventListener('install', (event) => { 13 | event.waitUntil( 14 | caches.open(CACHE_NAME) 15 | .then((cache) => { 16 | console.log('Opened cache'); 17 | return cache.addAll(urlsToCache); 18 | }) 19 | ); 20 | }); 21 | 22 | // Fetch event - serve from cache when offline 23 | self.addEventListener('fetch', (event) => { 24 | event.respondWith( 25 | caches.match(event.request) 26 | .then((response) => { 27 | // Return cached version or fetch from network 28 | return response || fetch(event.request); 29 | } 30 | ) 31 | ); 32 | }); 33 | 34 | // Background sync for queued actions 35 | self.addEventListener('sync', (event) => { 36 | if (event.tag === 'background-sync-todos') { 37 | event.waitUntil(doBackgroundSync()); 38 | } 39 | }); 40 | 41 | async function doBackgroundSync() { 42 | try { 43 | // Get queued actions from IndexedDB 44 | const syncQueue = JSON.parse(localStorage.getItem('sync-queue') || '[]'); 45 | 46 | if (syncQueue.length === 0) return; 47 | 48 | console.log('Background sync: processing', syncQueue.length, 'actions'); 49 | 50 | // Process each action 51 | for (const action of syncQueue) { 52 | try { 53 | await processAction(action); 54 | } catch (error) { 55 | console.error('Background sync failed for action:', action, error); 56 | } 57 | } 58 | 59 | // Notify the main app about sync completion 60 | self.clients.matchAll().then(clients => { 61 | clients.forEach(client => { 62 | client.postMessage({ 63 | type: 'BACKGROUND_SYNC_COMPLETE' 64 | }); 65 | }); 66 | }); 67 | 68 | } catch (error) { 69 | console.error('Background sync failed:', error); 70 | } 71 | } 72 | 73 | async function processAction(action) { 74 | switch (action.type) { 75 | case 'create': 76 | const formData = new FormData(); 77 | formData.append('text', action.data.text); 78 | if (action.data.image) { 79 | formData.append('image', action.data.image); 80 | } 81 | 82 | const response = await fetch('/todos/create-with-image', { 83 | method: 'POST', 84 | body: formData, 85 | credentials: 'include', 86 | }); 87 | 88 | if (!response.ok) { 89 | throw new Error('Failed to create todo'); 90 | } 91 | break; 92 | 93 | case 'update': 94 | // Handle update actions 95 | break; 96 | 97 | case 'delete': 98 | // Handle delete actions 99 | break; 100 | } 101 | } -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deploy to Production 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | workflow_dispatch: 7 | 8 | env: 9 | NODE_VERSION: '20' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | outputs: 15 | build-cache-key: ${{ steps.cache-key.outputs.value }} 16 | 17 | steps: 18 | - name: Checkout code 19 | uses: actions/checkout@v4 20 | 21 | - name: Setup Bun 22 | uses: oven-sh/setup-bun@v2 23 | with: 24 | bun-version: latest 25 | 26 | - name: Generate cache key 27 | id: cache-key 28 | run: echo "value=${{ github.sha }}-${{ github.run_id }}" >> $GITHUB_OUTPUT 29 | 30 | - name: Cache dependencies 31 | uses: actions/cache@v4 32 | with: 33 | path: | 34 | node_modules 35 | apps/*/node_modules 36 | .bun 37 | key: deps-${{ runner.os }}-${{ hashFiles('**/package.json', '**/bun.lockb') }} 38 | restore-keys: | 39 | deps-${{ runner.os }}- 40 | 41 | - name: Install dependencies 42 | run: bun install 43 | 44 | - name: Build types 45 | run: bun run build:types 46 | 47 | - name: Check types 48 | run: bun run check-types 49 | 50 | - name: Build applications 51 | run: bun run build 52 | 53 | - name: Upload build artifacts 54 | uses: actions/upload-artifact@v4 55 | with: 56 | name: build-artifacts 57 | path: | 58 | apps/web/dist 59 | apps/server/dist 60 | 61 | migrate-database: 62 | needs: build 63 | runs-on: ubuntu-latest 64 | environment: production 65 | 66 | steps: 67 | - name: Checkout code 68 | uses: actions/checkout@v4 69 | 70 | - name: Setup Bun 71 | uses: oven-sh/setup-bun@v2 72 | with: 73 | bun-version: latest 74 | 75 | - name: Download build artifacts 76 | uses: actions/download-artifact@v4 77 | with: 78 | name: build-artifacts 79 | 80 | - name: Install dependencies 81 | run: bun install 82 | 83 | - name: Run database migrations 84 | env: 85 | CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} 86 | DATABASE_URL: ${{ secrets.DATABASE_URL }} 87 | run: | 88 | cd apps/server 89 | bun run db:migrate 90 | 91 | notify: 92 | needs: [migrate-database] 93 | runs-on: ubuntu-latest 94 | if: always() 95 | 96 | steps: 97 | - name: Notify deployment success 98 | if: needs.migrate-database.result == 'success' 99 | run: | 100 | echo "✅ Deployment completed successfully!" 101 | echo "🗄️ Database migrations applied" 102 | 103 | - name: Notify deployment failure 104 | if: needs.migrate-database.result == 'failure' 105 | run: | 106 | echo "❌ Deployment failed!" 107 | echo "Database migration status: ${{ needs.migrate-database.result }}" 108 | echo "Please check the logs for more details." -------------------------------------------------------------------------------- /DEPLOYMENT.md: -------------------------------------------------------------------------------- 1 | # Deployment Guide 2 | 3 | This project uses GitHub Actions to automatically deploy changes to production when code is pushed to the `main` branch. 4 | 5 | ## GitHub Actions Workflow 6 | 7 | The deployment workflow (`.github/workflows/deploy.yml`) consists of three main jobs: 8 | 9 | 1. **Build Job**: Builds both the web and server applications 10 | 2. **Deploy Server Job**: Deploys the server to Cloudflare Workers and runs database migrations 11 | 3. **Deploy Web Job**: Deploys the web app to Cloudflare Pages 12 | 4. **Notify Job**: Provides deployment status notifications 13 | 14 | ## Required GitHub Secrets 15 | 16 | To enable automatic deployment, you need to configure the following secrets in your GitHub repository: 17 | 18 | ### 1. CLOUDFLARE_API_TOKEN 19 | - **Purpose**: Authenticates with Cloudflare for deploying to Workers and Pages 20 | - **How to get it**: 21 | 1. Log in to the Cloudflare dashboard ↗. 22 | 2. Select Manage Account > Account API Tokens. 23 | 3. Select Create Token > find Edit Cloudflare Workers > select Use Template. 24 | 4. Customize your token name. 25 | 5. Scope your token. 26 | 27 | ### 2. DATABASE_URL 28 | - **Purpose**: Connection string for the PostgreSQL database 29 | - **Format**: `postgresql://username:password@host:port/database` 30 | - **How to get it**: Copy from your database provider (e.g., Neon, Supabase, etc.) 31 | 32 | ## Setting up GitHub Secrets 33 | 34 | 1. Go to your GitHub repository 35 | 2. Navigate to **Settings** → **Secrets and variables** → **Actions** 36 | 3. Click **New repository secret** 37 | 4. Add each secret with the exact names above 38 | 39 | ## Manual Deployment 40 | 41 | You can also trigger deployments manually: 42 | 43 | 1. Go to your GitHub repository 44 | 2. Navigate to **Actions** tab 45 | 3. Select the **Deploy to Production** workflow 46 | 4. Click **Run workflow** → **Run workflow** 47 | 48 | ## Deployment Process 49 | 50 | 1. **Trigger**: Push to `main` branch or manual trigger 51 | 2. **Build**: Type checking and building both applications 52 | 3. **Deploy Server**: 53 | - Deploy to Cloudflare Workers 54 | - Run database migrations 55 | 4. **Deploy Web**: Deploy to Cloudflare Pages 56 | 5. **Notify**: Success/failure notifications 57 | 58 | ## Troubleshooting 59 | 60 | ### Common Issues 61 | 62 | 1. **Build failures**: Check TypeScript errors in the build logs 63 | 2. **Deployment failures**: Verify your Cloudflare API token has correct permissions 64 | 3. **Database migration failures**: Ensure DATABASE_URL is correct and database is accessible 65 | 66 | ### Logs 67 | 68 | - Build logs: Check the "build" job in GitHub Actions 69 | - Server deployment logs: Check the "deploy-server" job 70 | - Web deployment logs: Check the "deploy-web" job 71 | 72 | ## Environment Variables 73 | 74 | The following environment variables are used during deployment: 75 | 76 | - `CLOUDFLARE_API_TOKEN`: For Cloudflare authentication 77 | - `DATABASE_URL`: For database connections and migrations 78 | - `NODE_ENV`: Set to "production" in wrangler.jsonc 79 | 80 | ## Security Notes 81 | 82 | - Never commit secrets to the repository 83 | - Use GitHub Secrets for all sensitive data 84 | - Regularly rotate your Cloudflare API token 85 | - Monitor deployment logs for any security issues -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Future Stack 2 | 3 | This project was created with [Better-T-Stack](https://github.com/AmanVarshney01/create-better-t-stack), a modern TypeScript stack that combines React, TanStack Router, Hono, ORPC, and more. 4 | 5 | ## Features 6 | 7 | - **TypeScript** - For type safety and improved developer experience 8 | - **TanStack Router** - File-based routing with full type safety 9 | - **TailwindCSS** - Utility-first CSS for rapid UI development 10 | - **shadcn/ui** - Reusable UI components 11 | - **Hono** - Lightweight, performant server framework 12 | - **oRPC** - End-to-end type-safe APIs with OpenAPI integration 13 | - **workers** - Runtime environment 14 | - **Drizzle** - TypeScript-first ORM 15 | - **PostgreSQL** - Database engine 16 | - **Authentication** - Email & password authentication with Better Auth 17 | - **PWA** - Progressive Web App support with installation prompts 18 | - **Turborepo** - Optimized monorepo build system 19 | 20 | ## Screenshots 21 | 22 | ### Home Page 23 | ![Home Page](screenshots/home.png) 24 | 25 | ### Dashboard 26 | ![Dashboard](screenshots/dashboard.png) 27 | 28 | ### Todos 29 | ![Todos](screenshots/todos.png) 30 | 31 | ### Offline Todos 32 | ![Offline Todos](screenshots/offline%20todos.png) 33 | 34 | ### Admin Chat 35 | ![Admin Chat](screenshots/admin%20chat.png) 36 | 37 | ## Getting Started 38 | 39 | First, install the dependencies: 40 | 41 | ```bash 42 | bun install 43 | ``` 44 | ## Database Setup 45 | 46 | This project uses PostgreSQL with Drizzle ORM. 47 | 48 | 1. Make sure you have a PostgreSQL database set up. 49 | 2. Update your `apps/server/.env` file with your PostgreSQL connection details. 50 | 51 | 3. Apply the schema to your database: 52 | ```bash 53 | bun db:push 54 | ``` 55 | 56 | 57 | Then, run the development server: 58 | 59 | ```bash 60 | bun dev 61 | ``` 62 | 63 | Open [http://localhost:3001](http://localhost:3001) in your browser to see the web application. 64 | The API is running at [http://localhost:3000](http://localhost:3000). 65 | 66 | 67 | 68 | ## Project Structure 69 | 70 | ``` 71 | future-stack/ 72 | ├── apps/ 73 | │ ├── web/ # Frontend application (React + TanStack Router) 74 | │ └── server/ # Backend API (Hono, ORPC) 75 | ``` 76 | 77 | ## Available Scripts 78 | 79 | - `bun dev`: Start all applications in development mode 80 | - `bun build`: Build all applications 81 | - `bun dev:web`: Start only the web application 82 | - `bun dev:server`: Start only the server 83 | - `bun check-types`: Check TypeScript types across all apps 84 | - `bun db:push`: Push schema changes to database 85 | - `bun db:studio`: Open database studio UI 86 | - `cd apps/web && bun generate-pwa-assets`: Generate PWA assets 87 | 88 | ## PWA Installation 89 | 90 | The app includes a dedicated PWA installation page at `/install-pwa` that provides: 91 | 92 | - **Automatic Installation Detection** - Detects if the app is already installed 93 | - **Cross-Platform Support** - Works on iOS, Android, and Desktop browsers 94 | - **Installation Prompts** - Smart prompts that appear when the app can be installed 95 | - **Manual Instructions** - Step-by-step installation guides for different platforms 96 | - **Feature Showcase** - Highlights offline support, image handling, and native app features 97 | 98 | The PWA installation prompts also appear on the home page and offline todos page to encourage users to install the app for the best experience. 99 | -------------------------------------------------------------------------------- /test-health.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Health Check Test Script 5 | * 6 | * This script tests both the backend and frontend health endpoints 7 | * to ensure they're working correctly. 8 | */ 9 | 10 | const BASE_URL = process.env.BASE_URL || 'http://localhost:3000'; 11 | const WEB_URL = process.env.WEB_URL || 'http://localhost:3001'; 12 | 13 | async function testBackendHealth() { 14 | console.log('🔍 Testing Backend Health...'); 15 | 16 | try { 17 | const response = await fetch(`${BASE_URL}/health`); 18 | const data = await response.json(); 19 | 20 | console.log('✅ Backend Health Check Results:'); 21 | console.log(` Status: ${data.status}`); 22 | console.log(` Version: ${data.version}`); 23 | console.log(` Environment: ${data.environment}`); 24 | console.log(` Response Time: ${data.responseTime}`); 25 | console.log(' Checks:'); 26 | console.log(` Database: ${data.checks.database.status}`); 27 | console.log(` Storage: ${data.checks.storage.status}`); 28 | console.log(` Durable Objects: ${data.checks.durableObjects.status}`); 29 | 30 | return data.status === 'healthy'; 31 | } catch (error) { 32 | console.error('❌ Backend Health Check Failed:', error.message); 33 | return false; 34 | } 35 | } 36 | 37 | async function testFrontendHealth() { 38 | console.log('\n🔍 Testing Frontend Health...'); 39 | 40 | try { 41 | // Test if the frontend is accessible 42 | const response = await fetch(`${WEB_URL}/health`); 43 | 44 | if (response.ok) { 45 | console.log('✅ Frontend Health Page Accessible'); 46 | return true; 47 | } else { 48 | console.log(`❌ Frontend Health Page Failed: ${response.status}`); 49 | return false; 50 | } 51 | } catch (error) { 52 | console.error('❌ Frontend Health Check Failed:', error.message); 53 | return false; 54 | } 55 | } 56 | 57 | async function testSimpleBackendHealth() { 58 | console.log('\n🔍 Testing Simple Backend Health...'); 59 | 60 | try { 61 | const response = await fetch(`${BASE_URL}/`); 62 | const text = await response.text(); 63 | 64 | if (text === 'OK') { 65 | console.log('✅ Simple Backend Health Check Passed'); 66 | return true; 67 | } else { 68 | console.log(`❌ Simple Backend Health Check Failed: ${text}`); 69 | return false; 70 | } 71 | } catch (error) { 72 | console.error('❌ Simple Backend Health Check Failed:', error.message); 73 | return false; 74 | } 75 | } 76 | 77 | async function runAllTests() { 78 | console.log('🚀 Starting Health Check Tests...\n'); 79 | 80 | const backendHealth = await testBackendHealth(); 81 | const frontendHealth = await testFrontendHealth(); 82 | const simpleBackendHealth = await testSimpleBackendHealth(); 83 | 84 | console.log('\n📊 Test Results Summary:'); 85 | console.log(` Backend Health: ${backendHealth ? '✅ PASS' : '❌ FAIL'}`); 86 | console.log(` Frontend Health: ${frontendHealth ? '✅ PASS' : '❌ FAIL'}`); 87 | console.log(` Simple Backend: ${simpleBackendHealth ? '✅ PASS' : '❌ FAIL'}`); 88 | 89 | const allPassed = backendHealth && frontendHealth && simpleBackendHealth; 90 | 91 | if (allPassed) { 92 | console.log('\n🎉 All health checks passed!'); 93 | process.exit(0); 94 | } else { 95 | console.log('\n⚠️ Some health checks failed!'); 96 | process.exit(1); 97 | } 98 | } 99 | 100 | // Run tests if this script is executed directly 101 | if (import.meta.url === `file://${process.argv[1]}`) { 102 | runAllTests().catch(console.error); 103 | } 104 | 105 | export { testBackendHealth, testFrontendHealth, testSimpleBackendHealth }; -------------------------------------------------------------------------------- /apps/server/src/lib/r2.ts: -------------------------------------------------------------------------------- 1 | import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3"; 2 | import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; 3 | 4 | export function createR2Client(env: { CLOUDFLARE_ACCOUNT_ID: string; R2_ACCESS_KEY_ID: string; R2_SECRET_ACCESS_KEY: string }) { 5 | return new S3Client({ 6 | region: "auto", 7 | endpoint: `https://${env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`, 8 | credentials: { 9 | accessKeyId: env.R2_ACCESS_KEY_ID, 10 | secretAccessKey: env.R2_SECRET_ACCESS_KEY, 11 | }, 12 | }); 13 | } 14 | 15 | // New functions using R2 binding directly (recommended for Cloudflare Workers) 16 | export async function uploadImageToBinding( 17 | r2Bucket: any, // R2Bucket type 18 | key: string, 19 | file: File | ArrayBuffer | Uint8Array, 20 | contentType: string 21 | ) { 22 | let body: Uint8Array; 23 | 24 | if (file instanceof File) { 25 | body = new Uint8Array(await file.arrayBuffer()); 26 | } else if (file instanceof ArrayBuffer) { 27 | body = new Uint8Array(file); 28 | } else { 29 | body = file; // Already Uint8Array 30 | } 31 | 32 | await r2Bucket.put(key, body, { 33 | httpMetadata: { 34 | contentType: contentType, 35 | }, 36 | }); 37 | 38 | return key; 39 | } 40 | 41 | export async function getImageUrlFromBinding( 42 | r2Bucket: any, // R2Bucket type 43 | key: string, 44 | expiresIn: number = 3600 45 | ): Promise { 46 | try { 47 | // Check if the object exists 48 | const object = await r2Bucket.get(key); 49 | 50 | if (!object) { 51 | throw new Error(`Object not found: ${key}`); 52 | } 53 | 54 | // Return a URL that will be served through the Worker 55 | // This avoids the SSL issues with direct R2 URLs 56 | // The server URL should be configured in the environment 57 | const serverUrl = process.env.VITE_SERVER_URL || 'http://localhost:8787'; 58 | return `${serverUrl}/api/images/${key}`; 59 | } catch (error) { 60 | console.error('Error getting image URL from binding:', error); 61 | throw error; 62 | } 63 | } 64 | 65 | export async function uploadImage( 66 | r2: S3Client, 67 | bucketName: string, 68 | key: string, 69 | file: File | ArrayBuffer | Uint8Array, 70 | contentType: string 71 | ) { 72 | let body: Uint8Array; 73 | 74 | if (file instanceof File) { 75 | body = new Uint8Array(await file.arrayBuffer()); 76 | } else if (file instanceof ArrayBuffer) { 77 | body = new Uint8Array(file); 78 | } else { 79 | body = file; // Already Uint8Array 80 | } 81 | 82 | const command = new PutObjectCommand({ 83 | Bucket: bucketName, 84 | Key: key, 85 | Body: body, 86 | ContentType: contentType, 87 | }); 88 | 89 | await r2.send(command); 90 | return key; 91 | } 92 | 93 | export function generateImageKey(todoId: number, filename: string): string { 94 | const timestamp = Date.now(); 95 | const extension = filename.split('.').pop(); 96 | return `todos/${todoId}/${timestamp}.${extension}`; 97 | } 98 | 99 | export async function getImageUrl(r2: S3Client, bucketName: string, key: string, expiresIn: number = 3600): Promise { 100 | const command = new GetObjectCommand({ 101 | Bucket: bucketName, 102 | Key: key, 103 | }); 104 | 105 | // Generate a signed URL with configurable expiration 106 | const signedUrl = await getSignedUrl(r2, command, { expiresIn }); 107 | return signedUrl; 108 | } 109 | 110 | // New function to generate a fresh signed URL for an image key 111 | export async function generateFreshImageUrl(r2: S3Client, bucketName: string, key: string, expiresIn: number = 3600): Promise { 112 | return getImageUrl(r2, bucketName, key, expiresIn); 113 | } -------------------------------------------------------------------------------- /apps/web/src/components/sign-in-form.tsx: -------------------------------------------------------------------------------- 1 | import { authClient } from "@/lib/auth-client"; 2 | import { useForm } from "@tanstack/react-form"; 3 | import { useNavigate } from "@tanstack/react-router"; 4 | import { toast } from "sonner"; 5 | import z from "zod"; 6 | import Loader from "./loader"; 7 | import { Button } from "./ui/button"; 8 | import { Input } from "./ui/input"; 9 | import { Label } from "./ui/label"; 10 | 11 | export default function SignInForm({ 12 | onSwitchToSignUp, 13 | }: { 14 | onSwitchToSignUp: () => void; 15 | }) { 16 | const navigate = useNavigate({ 17 | from: "/", 18 | }); 19 | const { isPending } = authClient.useSession(); 20 | 21 | const form = useForm({ 22 | defaultValues: { 23 | email: "", 24 | password: "", 25 | }, 26 | onSubmit: async ({ value }) => { 27 | await authClient.signIn.email( 28 | { 29 | email: value.email, 30 | password: value.password, 31 | }, 32 | { 33 | onSuccess: () => { 34 | navigate({ 35 | to: "/dashboard", 36 | }); 37 | toast.success("Sign in successful"); 38 | }, 39 | onError: (error) => { 40 | toast.error(error.error.message); 41 | }, 42 | }, 43 | ); 44 | }, 45 | validators: { 46 | onSubmit: z.object({ 47 | email: z.email("Invalid email address"), 48 | password: z.string().min(8, "Password must be at least 8 characters"), 49 | }), 50 | }, 51 | }); 52 | 53 | if (isPending) { 54 | return ; 55 | } 56 | 57 | return ( 58 |
59 |

Welcome Back

60 | 61 |
{ 63 | e.preventDefault(); 64 | e.stopPropagation(); 65 | void form.handleSubmit(); 66 | }} 67 | className="space-y-4" 68 | > 69 |
70 | 71 | {(field) => ( 72 |
73 | 74 | field.handleChange(e.target.value)} 81 | /> 82 | {field.state.meta.errors.map((error) => ( 83 |

84 | {error?.message} 85 |

86 | ))} 87 |
88 | )} 89 |
90 |
91 | 92 |
93 | 94 | {(field) => ( 95 |
96 | 97 | field.handleChange(e.target.value)} 104 | /> 105 | {field.state.meta.errors.map((error) => ( 106 |

107 | {error?.message} 108 |

109 | ))} 110 |
111 | )} 112 |
113 |
114 | 115 | 116 | {(state) => ( 117 | 124 | )} 125 | 126 |
127 | 128 |
129 | 136 |
137 |
138 | ); 139 | } 140 | -------------------------------------------------------------------------------- /SETUP.md: -------------------------------------------------------------------------------- 1 | # Future Stack Setup Guide 2 | 3 | ## Environment Variables Setup 4 | 5 | ### 1. Database (PostgreSQL) 6 | 7 | **Option A: Neon.tech (Recommended)** 8 | 1. Go to [neon.tech](https://neon.tech) and create a free account 9 | 2. Create a new project 10 | 3. Copy the connection strings: 11 | - `DATABASE_URL` - Direct connection string 12 | 13 | **Option B: Local PostgreSQL** 14 | ```bash 15 | # Install PostgreSQL locally 16 | # Create database and user 17 | createdb future_stack 18 | createuser -P future_stack_user 19 | ``` 20 | 21 | ### 2. Authentication (Better Auth) 22 | 23 | Generate a secure secret: 24 | ```bash 25 | # Generate a random 32-character secret 26 | openssl rand -base64 32 27 | ``` 28 | 29 | Set the URLs: 30 | - `BETTER_AUTH_SECRET` - Your generated secret 31 | - `BETTER_AUTH_URL` - Backend URL (http://localhost:3000 for dev) 32 | 33 | ### 3. CORS Configuration 34 | 35 | Set `CORS_ORIGIN` to your frontend URL: 36 | - Development: `http://localhost:3001` 37 | - Production: `https://yourdomain.com` 38 | 39 | ### 4. Cloudflare R2 (Image Storage) 40 | 41 | 1. Go to [Cloudflare Dashboard](https://dash.cloudflare.com) 42 | 2. Navigate to R2 Object Storage 43 | 3. Create API token with R2 permissions: 44 | - Go to "My Profile" → "API Tokens" 45 | - Create token with R2:Edit permissions 46 | 4. Create R2 bucket: 47 | ```bash 48 | wrangler r2 bucket create future_stack-todo-images 49 | ``` 50 | 5. Get your Account ID from the right sidebar 51 | 52 | Set these variables: 53 | - `CLOUDFLARE_ACCOUNT_ID` - Your Cloudflare Account ID 54 | - `R2_ACCESS_KEY_ID` - API token Access Key ID 55 | - `R2_SECRET_ACCESS_KEY` - API token Secret Access Key 56 | 57 | ## Setup Commands 58 | 59 | 1. **Copy environment file:** 60 | ```bash 61 | cp apps/server/.env.example apps/server/.env 62 | ``` 63 | 64 | 2. **Fill in your environment variables** in `apps/server/.env` 65 | 66 | 3. **Install dependencies:** 67 | ```bash 68 | bun install 69 | ``` 70 | 71 | 4. **Generate and push database schema:** 72 | ```bash 73 | bun run db:generate 74 | bun run db:push 75 | ``` 76 | 77 | 5. **Create admin user:** 78 | After setting up the database, manually set a user as admin: 79 | ```sql 80 | UPDATE "user" SET is_admin = true WHERE email = 'your-admin-email@example.com'; 81 | ``` 82 | 83 | 6. **Start development servers:** 84 | ```bash 85 | # Start backend (in one terminal) 86 | bun run dev:server 87 | 88 | # Start frontend (in another terminal) 89 | bun run dev:web 90 | ``` 91 | 92 | ## Production Deployment 93 | 94 | For Cloudflare Workers deployment: 95 | 96 | 1. **Set secrets** (don't put these in wrangler.jsonc): 97 | ```bash 98 | wrangler secret put BETTER_AUTH_SECRET 99 | wrangler secret put DATABASE_URL 100 | wrangler secret put CLOUDFLARE_ACCOUNT_ID 101 | wrangler secret put R2_ACCESS_KEY_ID 102 | wrangler secret put R2_SECRET_ACCESS_KEY 103 | ``` 104 | 105 | 2. **Update wrangler.jsonc** with production CORS_ORIGIN in vars section 106 | 107 | 3. **Deploy:** 108 | ```bash 109 | bun run deploy 110 | ``` 111 | 112 | ## Features Included 113 | 114 | - ✅ Todo management with image attachments 115 | - ✅ User authentication (email/password) 116 | - ✅ Admin-only chat system (WebSocket) 117 | - ✅ Image storage via Cloudflare R2 118 | - ✅ Secure WebSocket connections 119 | - ✅ Database migrations with Drizzle ORM 120 | 121 | ## Troubleshooting 122 | 123 | **Database connection issues:** 124 | - Ensure your DATABASE_URL is correct 125 | - Check if your database allows external connections 126 | - Verify SSL settings match your provider 127 | 128 | **R2 upload issues:** 129 | - Verify your API token has R2:Edit permissions 130 | - Check that the bucket name matches in wrangler.jsonc 131 | - Ensure CLOUDFLARE_ACCOUNT_ID is correct 132 | 133 | **WebSocket connection fails:** 134 | - Verify user has admin role in database 135 | - Check that session is valid 136 | - Ensure WebSocket upgrade headers are being sent -------------------------------------------------------------------------------- /apps/web/src/routes/public-chat.tsx: -------------------------------------------------------------------------------- 1 | import { authClient } from "@/lib/auth-client"; 2 | import { useNavigate } from "@tanstack/react-router"; 3 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import { Button } from "@/components/ui/button"; 5 | import { MessageCircle, LogIn } from "lucide-react"; 6 | import PublicChat from "@/components/public-chat"; 7 | import { createFileRoute } from "@tanstack/react-router"; 8 | 9 | export const Route = createFileRoute('/public-chat')({ 10 | component: PublicChatPage, 11 | }); 12 | 13 | function PublicChatPage() { 14 | const { data: session, isPending } = authClient.useSession(); 15 | const navigate = useNavigate(); 16 | 17 | if (isPending) { 18 | return ( 19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 | ); 29 | } 30 | 31 | if (!session) { 32 | return ( 33 |
34 |
35 |
36 | 37 |

Public Chat

38 |

39 | View the conversation in real-time or sign in to participate 40 |

41 |
42 | 46 | 49 |
50 |
51 | 52 | 53 | 54 | About Public Chat 55 | 56 | Connect with other users in our community 57 | 58 | 59 | 60 |
61 |
62 |
💬
63 |

Real-time Messaging

64 |

65 | Send and receive messages instantly 66 |

67 |
68 |
69 |
👥
70 |

Community

71 |

72 | Connect with users from around the world 73 |

74 |
75 |
76 |
🖼️
77 |

Profile Pictures

78 |

79 | Show your personality with custom avatars 80 |

81 |
82 |
83 |
84 |
85 | 86 | {/* Guest Chat View */} 87 |
88 |

Live Chat (View Only)

89 | 90 |
91 |
92 |
93 | ); 94 | } 95 | 96 | return ( 97 |
98 |
99 |
100 |

Public Chat

101 |

102 | Chat with other users in real-time. Be respectful and have fun! 103 |

104 |
105 | 106 | 107 |
108 |
109 | ); 110 | } -------------------------------------------------------------------------------- /apps/web/src/components/user-menu.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DropdownMenu, 3 | DropdownMenuContent, 4 | DropdownMenuItem, 5 | DropdownMenuLabel, 6 | DropdownMenuSeparator, 7 | DropdownMenuTrigger, 8 | } from "@/components/ui/dropdown-menu"; 9 | import { authClient } from "@/lib/auth-client"; 10 | import { useNavigate } from "@tanstack/react-router"; 11 | import { Button } from "./ui/button"; 12 | import { Skeleton } from "./ui/skeleton"; 13 | import { Link } from "@tanstack/react-router"; 14 | import { useState, useEffect } from "react"; 15 | import { User, Settings, LogOut } from "lucide-react"; 16 | import { orpc } from "@/utils/orpc"; 17 | 18 | export default function UserMenu() { 19 | const navigate = useNavigate(); 20 | const { data: session, isPending } = authClient.useSession(); 21 | const [profilePictureUrl, setProfilePictureUrl] = useState(null); 22 | 23 | useEffect(() => { 24 | if (!session?.user?.id) return; 25 | 26 | const loadProfilePicture = async () => { 27 | try { 28 | const result = await orpc.profile.getProfilePictureUrl.call({ 29 | userId: session.user.id, 30 | }); 31 | setProfilePictureUrl(result?.imageUrl || null); 32 | } catch (error) { 33 | console.error('Error loading profile picture:', error); 34 | } 35 | }; 36 | 37 | loadProfilePicture(); 38 | }, [session?.user?.id]); 39 | 40 | if (isPending) { 41 | return ; 42 | } 43 | 44 | if (!session) { 45 | return ( 46 | 49 | ); 50 | } 51 | 52 | const getUserInitials = (name: string) => { 53 | return name 54 | .split(' ') 55 | .map(word => word[0]) 56 | .join('') 57 | .toUpperCase() 58 | .slice(0, 2); 59 | }; 60 | 61 | return ( 62 | 63 | 64 | 84 | 85 | 86 | My Account 87 | 88 | 89 | {session.user.email} 90 | 91 | 92 | 93 | 94 | 95 | Profile Settings 96 | 97 | 98 | 99 | 100 | 101 | Public Chat 102 | 103 | 104 | 105 | 106 | 125 | 126 | 127 | 128 | ); 129 | } 130 | -------------------------------------------------------------------------------- /apps/web/src/components/sign-up-form.tsx: -------------------------------------------------------------------------------- 1 | import { authClient } from "@/lib/auth-client"; 2 | import { useForm } from "@tanstack/react-form"; 3 | import { useNavigate } from "@tanstack/react-router"; 4 | import { toast } from "sonner"; 5 | import z from "zod"; 6 | import Loader from "./loader"; 7 | import { Button } from "./ui/button"; 8 | import { Input } from "./ui/input"; 9 | import { Label } from "./ui/label"; 10 | 11 | export default function SignUpForm({ 12 | onSwitchToSignIn, 13 | }: { 14 | onSwitchToSignIn: () => void; 15 | }) { 16 | const navigate = useNavigate({ 17 | from: "/", 18 | }); 19 | const { isPending } = authClient.useSession(); 20 | 21 | const form = useForm({ 22 | defaultValues: { 23 | email: "", 24 | password: "", 25 | name: "", 26 | }, 27 | onSubmit: async ({ value }) => { 28 | await authClient.signUp.email( 29 | { 30 | email: value.email, 31 | password: value.password, 32 | name: value.name, 33 | }, 34 | { 35 | onSuccess: () => { 36 | navigate({ 37 | to: "/dashboard", 38 | }); 39 | toast.success("Sign up successful"); 40 | }, 41 | onError: (error) => { 42 | toast.error(error.error.message); 43 | }, 44 | }, 45 | ); 46 | }, 47 | validators: { 48 | onSubmit: z.object({ 49 | name: z.string().min(2, "Name must be at least 2 characters"), 50 | email: z.email("Invalid email address"), 51 | password: z.string().min(8, "Password must be at least 8 characters"), 52 | }), 53 | }, 54 | }); 55 | 56 | if (isPending) { 57 | return ; 58 | } 59 | 60 | return ( 61 |
62 |

Create Account

63 | 64 |
{ 66 | e.preventDefault(); 67 | e.stopPropagation(); 68 | void form.handleSubmit(); 69 | }} 70 | className="space-y-4" 71 | > 72 |
73 | 74 | {(field) => ( 75 |
76 | 77 | field.handleChange(e.target.value)} 83 | /> 84 | {field.state.meta.errors.map((error) => ( 85 |

86 | {error?.message} 87 |

88 | ))} 89 |
90 | )} 91 |
92 |
93 | 94 |
95 | 96 | {(field) => ( 97 |
98 | 99 | field.handleChange(e.target.value)} 106 | /> 107 | {field.state.meta.errors.map((error) => ( 108 |

109 | {error?.message} 110 |

111 | ))} 112 |
113 | )} 114 |
115 |
116 | 117 |
118 | 119 | {(field) => ( 120 |
121 | 122 | field.handleChange(e.target.value)} 129 | /> 130 | {field.state.meta.errors.map((error) => ( 131 |

132 | {error?.message} 133 |

134 | ))} 135 |
136 | )} 137 |
138 |
139 | 140 | 141 | {(state) => ( 142 | 149 | )} 150 | 151 |
152 | 153 |
154 | 161 |
162 |
163 | ); 164 | } 165 | -------------------------------------------------------------------------------- /apps/web/src/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss"; 2 | @import "tw-animate-css"; 3 | 4 | @custom-variant dark (&:where(.dark, .dark *)); 5 | 6 | @theme { 7 | --font-sans: "Inter", "Geist", ui-sans-serif, system-ui, sans-serif, 8 | "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; 9 | } 10 | 11 | html, 12 | body { 13 | @apply bg-white dark:bg-gray-950; 14 | 15 | @media (prefers-color-scheme: dark) { 16 | color-scheme: dark; 17 | } 18 | } 19 | 20 | :root { 21 | --radius: 0.625rem; 22 | --background: oklch(1 0 0); 23 | --foreground: oklch(0.145 0 0); 24 | --card: oklch(1 0 0); 25 | --card-foreground: oklch(0.145 0 0); 26 | --popover: oklch(1 0 0); 27 | --popover-foreground: oklch(0.145 0 0); 28 | --primary: oklch(0.205 0 0); 29 | --primary-foreground: oklch(0.985 0 0); 30 | --secondary: oklch(0.97 0 0); 31 | --secondary-foreground: oklch(0.205 0 0); 32 | --muted: oklch(0.97 0 0); 33 | --muted-foreground: oklch(0.556 0 0); 34 | --accent: oklch(0.97 0 0); 35 | --accent-foreground: oklch(0.205 0 0); 36 | --destructive: oklch(0.577 0.245 27.325); 37 | --border: oklch(0.922 0 0); 38 | --input: oklch(0.922 0 0); 39 | --ring: oklch(0.708 0 0); 40 | --chart-1: oklch(0.646 0.222 41.116); 41 | --chart-2: oklch(0.6 0.118 184.704); 42 | --chart-3: oklch(0.398 0.07 227.392); 43 | --chart-4: oklch(0.828 0.189 84.429); 44 | --chart-5: oklch(0.769 0.188 70.08); 45 | --sidebar: oklch(0.985 0 0); 46 | --sidebar-foreground: oklch(0.145 0 0); 47 | --sidebar-primary: oklch(0.205 0 0); 48 | --sidebar-primary-foreground: oklch(0.985 0 0); 49 | --sidebar-accent: oklch(0.97 0 0); 50 | --sidebar-accent-foreground: oklch(0.205 0 0); 51 | --sidebar-border: oklch(0.922 0 0); 52 | --sidebar-ring: oklch(0.708 0 0); 53 | } 54 | 55 | .dark { 56 | --background: oklch(0.145 0 0); 57 | --foreground: oklch(0.985 0 0); 58 | --card: oklch(0.205 0 0); 59 | --card-foreground: oklch(0.985 0 0); 60 | --popover: oklch(0.205 0 0); 61 | --popover-foreground: oklch(0.985 0 0); 62 | --primary: oklch(0.922 0 0); 63 | --primary-foreground: oklch(0.205 0 0); 64 | --secondary: oklch(0.269 0 0); 65 | --secondary-foreground: oklch(0.985 0 0); 66 | --muted: oklch(0.269 0 0); 67 | --muted-foreground: oklch(0.708 0 0); 68 | --accent: oklch(0.269 0 0); 69 | --accent-foreground: oklch(0.985 0 0); 70 | --destructive: oklch(0.704 0.191 22.216); 71 | --border: oklch(1 0 0 / 10%); 72 | --input: oklch(1 0 0 / 15%); 73 | --ring: oklch(0.556 0 0); 74 | --chart-1: oklch(0.488 0.243 264.376); 75 | --chart-2: oklch(0.696 0.17 162.48); 76 | --chart-3: oklch(0.769 0.188 70.08); 77 | --chart-4: oklch(0.627 0.265 303.9); 78 | --chart-5: oklch(0.645 0.246 16.439); 79 | --sidebar: oklch(0.205 0 0); 80 | --sidebar-foreground: oklch(0.985 0 0); 81 | --sidebar-primary: oklch(0.488 0.243 264.376); 82 | --sidebar-primary-foreground: oklch(0.985 0 0); 83 | --sidebar-accent: oklch(0.269 0 0); 84 | --sidebar-accent-foreground: oklch(0.985 0 0); 85 | --sidebar-border: oklch(1 0 0 / 10%); 86 | --sidebar-ring: oklch(0.556 0 0); 87 | } 88 | 89 | @theme inline { 90 | --radius-sm: calc(var(--radius) - 4px); 91 | --radius-md: calc(var(--radius) - 2px); 92 | --radius-lg: var(--radius); 93 | --radius-xl: calc(var(--radius) + 4px); 94 | --color-background: var(--background); 95 | --color-foreground: var(--foreground); 96 | --color-card: var(--card); 97 | --color-card-foreground: var(--card-foreground); 98 | --color-popover: var(--popover); 99 | --color-popover-foreground: var(--popover-foreground); 100 | --color-primary: var(--primary); 101 | --color-primary-foreground: var(--primary-foreground); 102 | --color-secondary: var(--secondary); 103 | --color-secondary-foreground: var(--secondary-foreground); 104 | --color-muted: var(--muted); 105 | --color-muted-foreground: var(--muted-foreground); 106 | --color-accent: var(--accent); 107 | --color-accent-foreground: var(--accent-foreground); 108 | --color-destructive: var(--destructive); 109 | --color-border: var(--border); 110 | --color-input: var(--input); 111 | --color-ring: var(--ring); 112 | --color-chart-1: var(--chart-1); 113 | --color-chart-2: var(--chart-2); 114 | --color-chart-3: var(--chart-3); 115 | --color-chart-4: var(--chart-4); 116 | --color-chart-5: var(--chart-5); 117 | --color-sidebar: var(--sidebar); 118 | --color-sidebar-foreground: var(--sidebar-foreground); 119 | --color-sidebar-primary: var(--sidebar-primary); 120 | --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); 121 | --color-sidebar-accent: var(--sidebar-accent); 122 | --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); 123 | --color-sidebar-border: var(--sidebar-border); 124 | --color-sidebar-ring: var(--sidebar-ring); 125 | } 126 | 127 | @layer base { 128 | * { 129 | @apply border-border outline-ring/50; 130 | } 131 | body { 132 | @apply bg-background text-foreground; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /apps/server/src/routers/profile.ts: -------------------------------------------------------------------------------- 1 | import { eq } from "drizzle-orm"; 2 | import z from "zod"; 3 | import { user } from "../db/schema/auth"; 4 | import { publicProcedure, o } from "../lib/orpc"; 5 | import { createDatabaseConnection } from "../lib/db-factory"; 6 | import { uploadImageToBinding, getImageUrlFromBinding } from "../lib/r2"; 7 | import type { Env } from "../types/global"; 8 | 9 | export const profileRouter = o.router({ 10 | uploadProfilePicture: publicProcedure 11 | .input(z.object({ 12 | userId: z.string(), 13 | fileData: z.string(), // base64 encoded file 14 | filename: z.string(), 15 | contentType: z.string(), 16 | })) 17 | .handler(async ({ input, context }) => { 18 | const env = context.env as Env; 19 | const db = createDatabaseConnection(); 20 | 21 | // Validate file type 22 | if (!input.contentType.startsWith('image/')) { 23 | throw new Error('File must be an image'); 24 | } 25 | 26 | try { 27 | // Decode base64 file data 28 | const fileBuffer = Uint8Array.from(atob(input.fileData), c => c.charCodeAt(0)); 29 | 30 | // Validate file size (max 5MB) 31 | if (fileBuffer.length > 5 * 1024 * 1024) { 32 | throw new Error('File size must be less than 5MB'); 33 | } 34 | 35 | // Generate unique key for the profile picture 36 | const timestamp = Date.now(); 37 | const extension = input.filename.split('.').pop() || 'jpg'; 38 | const key = `profile-pictures/${input.userId}/${timestamp}.${extension}`; 39 | 40 | // Upload to R2 using the binding 41 | await uploadImageToBinding(env.TODO_IMAGES, key, fileBuffer, input.contentType); 42 | 43 | // Update user record with the new profile picture key 44 | await db 45 | .update(user) 46 | .set({ 47 | profilePicture: key, 48 | updatedAt: new Date() 49 | }) 50 | .where(eq(user.id, input.userId)); 51 | 52 | // Generate URL for immediate access 53 | const serverUrl = context.req?.url ? new URL(context.req.url).origin : 'http://localhost:8787'; 54 | const imageUrl = `${serverUrl}/api/images/${key}`; 55 | 56 | return { 57 | success: true, 58 | profilePictureKey: key, 59 | imageUrl 60 | }; 61 | } catch (error) { 62 | console.error('Error uploading profile picture:', error); 63 | throw new Error('Failed to upload profile picture'); 64 | } 65 | }), 66 | 67 | getProfilePictureUrl: publicProcedure 68 | .input(z.object({ 69 | userId: z.string(), 70 | })) 71 | .handler(async ({ input, context }) => { 72 | const env = context.env as Env; 73 | const db = createDatabaseConnection(); 74 | 75 | // Get user's profile picture key 76 | const userRecord = await db 77 | .select({ profilePicture: user.profilePicture }) 78 | .from(user) 79 | .where(eq(user.id, input.userId)) 80 | .limit(1); 81 | 82 | if (!userRecord[0]?.profilePicture) { 83 | return { imageUrl: null }; 84 | } 85 | 86 | try { 87 | const serverUrl = context.req?.url ? new URL(context.req.url).origin : 'http://localhost:8787'; 88 | const imageUrl = `${serverUrl}/api/images/${userRecord[0].profilePicture}`; 89 | return { imageUrl }; 90 | } catch (error) { 91 | console.error('Error generating profile picture URL:', error); 92 | return { imageUrl: null }; 93 | } 94 | }), 95 | 96 | getUserProfile: publicProcedure 97 | .input(z.object({ 98 | userId: z.string(), 99 | })) 100 | .handler(async ({ input, context }) => { 101 | const env = context.env as Env; 102 | const db = createDatabaseConnection(); 103 | 104 | const userRecord = await db 105 | .select({ 106 | id: user.id, 107 | name: user.name, 108 | email: user.email, 109 | profilePicture: user.profilePicture, 110 | isAdmin: user.isAdmin, 111 | createdAt: user.createdAt, 112 | }) 113 | .from(user) 114 | .where(eq(user.id, input.userId)) 115 | .limit(1); 116 | 117 | if (!userRecord[0]) { 118 | throw new Error('User not found'); 119 | } 120 | 121 | // Generate URL for profile picture if it exists 122 | let profilePictureUrl: string | null = null; 123 | if (userRecord[0].profilePicture) { 124 | try { 125 | const serverUrl = context.req?.url ? new URL(context.req.url).origin : 'http://localhost:8787'; 126 | profilePictureUrl = `${serverUrl}/api/images/${userRecord[0].profilePicture}`; 127 | } catch (error) { 128 | console.error('Error generating profile picture URL:', error); 129 | } 130 | } 131 | 132 | return { 133 | id: userRecord[0].id, 134 | name: userRecord[0].name, 135 | email: userRecord[0].email, 136 | profilePicture: userRecord[0].profilePicture, 137 | profilePictureUrl, 138 | isAdmin: userRecord[0].isAdmin, 139 | createdAt: userRecord[0].createdAt, 140 | }; 141 | }), 142 | }); -------------------------------------------------------------------------------- /apps/web/src/components/pwa-install-prompt.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import { Download, X, CheckCircle } from "lucide-react"; 4 | import { useEffect, useState } from "react"; 5 | import { Link } from "@tanstack/react-router"; 6 | 7 | interface PWAInstallPromptProps { 8 | onDismiss?: () => void; 9 | variant?: "banner" | "card"; 10 | } 11 | 12 | export default function PWAInstallPrompt({ onDismiss, variant = "banner" }: PWAInstallPromptProps) { 13 | const [deferredPrompt, setDeferredPrompt] = useState(null); 14 | const [isInstalled, setIsInstalled] = useState(false); 15 | const [isStandalone, setIsStandalone] = useState(false); 16 | const [showPrompt, setShowPrompt] = useState(false); 17 | 18 | useEffect(() => { 19 | // Check if app is already installed 20 | setIsStandalone(window.matchMedia('(display-mode: standalone)').matches); 21 | 22 | // Check if app is installed via other methods 23 | if ('getInstalledRelatedApps' in navigator) { 24 | (navigator as any).getInstalledRelatedApps().then((relatedApps: any[]) => { 25 | setIsInstalled(relatedApps.length > 0); 26 | }); 27 | } 28 | 29 | // Listen for beforeinstallprompt event 30 | const handleBeforeInstallPrompt = (e: Event) => { 31 | e.preventDefault(); 32 | setDeferredPrompt(e); 33 | setShowPrompt(true); 34 | }; 35 | 36 | window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 37 | 38 | return () => { 39 | window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 40 | }; 41 | }, []); 42 | 43 | const handleInstallClick = async () => { 44 | if (!deferredPrompt) { 45 | // Fallback for browsers that don't support beforeinstallprompt 46 | showInstallInstructions(); 47 | return; 48 | } 49 | 50 | deferredPrompt.prompt(); 51 | const { outcome } = await deferredPrompt.userChoice; 52 | 53 | if (outcome === 'accepted') { 54 | setIsInstalled(true); 55 | setDeferredPrompt(null); 56 | setShowPrompt(false); 57 | } 58 | }; 59 | 60 | const showInstallInstructions = () => { 61 | const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 62 | const isAndroid = /Android/.test(navigator.userAgent); 63 | 64 | if (isIOS) { 65 | alert('To install: Tap the Share button and select "Add to Home Screen"'); 66 | } else if (isAndroid) { 67 | alert('To install: Tap the menu button and select "Add to Home Screen" or "Install App"'); 68 | } else { 69 | alert('To install: Click the install icon in your browser\'s address bar or use the browser menu'); 70 | } 71 | }; 72 | 73 | const handleDismiss = () => { 74 | setShowPrompt(false); 75 | onDismiss?.(); 76 | }; 77 | 78 | // Don't show if already installed or in standalone mode 79 | if (isInstalled || isStandalone || !showPrompt) { 80 | return null; 81 | } 82 | 83 | if (variant === "card") { 84 | return ( 85 | 86 | 87 |
88 |
89 |
90 | 91 |
92 |
93 | Install Ecomantem 94 | 95 | Get the full experience with offline support 96 | 97 |
98 |
99 | 107 |
108 |
109 | 110 |
111 | 115 | 118 |
119 |
120 |
121 | ); 122 | } 123 | 124 | // Banner variant 125 | return ( 126 |
127 |
128 |
129 | 130 |
131 |

Install Ecomantem

132 |

Get offline support and native app features

133 |
134 |
135 |
136 | 139 | 142 |
143 |
144 |
145 | ); 146 | } -------------------------------------------------------------------------------- /apps/web/src/components/header.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@tanstack/react-router"; 2 | import { authClient } from "@/lib/auth-client"; 3 | import { orpc } from "@/utils/orpc"; 4 | import { useQuery } from "@tanstack/react-query"; 5 | import { useState, useEffect } from "react"; 6 | import { Menu, X, MessageCircle } from "lucide-react"; 7 | 8 | import { ModeToggle } from "./mode-toggle"; 9 | import UserMenu from "./user-menu"; 10 | import { Button } from "./ui/button"; 11 | 12 | export default function Header() { 13 | const { data: session } = authClient.useSession(); 14 | const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); 15 | 16 | // Check if user is admin 17 | const adminCheck = useQuery({ 18 | ...orpc.adminChat.checkAdminStatus.queryOptions({ 19 | input: { userId: session?.user?.id || "" }, 20 | }), 21 | enabled: !!session?.user?.id, 22 | }); 23 | 24 | const baseLinks = [ 25 | { to: "/", label: "Home" }, 26 | { to: "/dashboard", label: "Dashboard" }, 27 | { to: "/todos", label: "Todos" }, 28 | { to: "/todos-offline", label: "Offline Todos" }, 29 | { to: "/public-chat", label: "Public Chat", icon: MessageCircle }, 30 | { to: "/install-pwa", label: "Install App" }, 31 | { to: "/health", label: "Health" }, 32 | ]; 33 | 34 | const links = (adminCheck.data as { isAdmin: boolean })?.isAdmin 35 | ? [...baseLinks, { to: "/admin-chat", label: "Admin Chat" }] 36 | : baseLinks; 37 | 38 | const toggleMobileMenu = () => { 39 | setIsMobileMenuOpen(!isMobileMenuOpen); 40 | }; 41 | 42 | const closeMobileMenu = () => { 43 | setIsMobileMenuOpen(false); 44 | }; 45 | 46 | // Close mobile menu on escape key 47 | useEffect(() => { 48 | const handleEscape = (e: KeyboardEvent) => { 49 | if (e.key === "Escape") { 50 | closeMobileMenu(); 51 | } 52 | }; 53 | 54 | if (isMobileMenuOpen) { 55 | document.addEventListener("keydown", handleEscape); 56 | // Prevent body scroll when menu is open 57 | document.body.style.overflow = "hidden"; 58 | } 59 | 60 | return () => { 61 | document.removeEventListener("keydown", handleEscape); 62 | document.body.style.overflow = "unset"; 63 | }; 64 | }, [isMobileMenuOpen]); 65 | 66 | return ( 67 |
68 |
69 | {/* Logo/Brand */} 70 |
71 | 75 |
76 | A 77 |
78 | App 79 | 80 |
81 | 82 | {/* Desktop Navigation - Hidden on tablet, shown on desktop */} 83 | 97 | 98 | {/* Desktop Right Side */} 99 |
100 | 101 | 102 | 103 | {/* Mobile/Tablet Menu Button */} 104 | 118 |
119 |
120 | 121 | {/* Mobile/Tablet Navigation Menu */} 122 | {isMobileMenuOpen && ( 123 | <> 124 | {/* Backdrop */} 125 |
129 | 130 | {/* Menu */} 131 |
132 | 147 |
148 | 149 | )} 150 |
151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /apps/web/src/components/profile-picture-upload.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef } from "react"; 2 | import { Button } from "@/components/ui/button"; 3 | import { Input } from "./ui/input"; 4 | import { Label } from "./ui/label"; 5 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card"; 6 | import { Upload, X, User } from "lucide-react"; 7 | import { toast } from "sonner"; 8 | import { orpc } from "@/utils/orpc"; 9 | 10 | interface ProfilePictureUploadProps { 11 | userId: string; 12 | currentImageUrl?: string | null; 13 | onUploadSuccess: (imageUrl: string) => void; 14 | className?: string; 15 | } 16 | 17 | export default function ProfilePictureUpload({ 18 | userId, 19 | currentImageUrl, 20 | onUploadSuccess, 21 | className = "", 22 | }: ProfilePictureUploadProps) { 23 | const [isUploading, setIsUploading] = useState(false); 24 | const [dragActive, setDragActive] = useState(false); 25 | const fileInputRef = useRef(null); 26 | 27 | const handleFileUpload = async (file: File) => { 28 | if (!file) return; 29 | 30 | // Validate file type 31 | if (!file.type.startsWith('image/')) { 32 | toast.error('Please select an image file'); 33 | return; 34 | } 35 | 36 | // Validate file size (max 5MB) 37 | if (file.size > 5 * 1024 * 1024) { 38 | toast.error('File size must be less than 5MB'); 39 | return; 40 | } 41 | 42 | setIsUploading(true); 43 | 44 | try { 45 | // Convert file to base64 46 | const base64Data = await new Promise((resolve, reject) => { 47 | const reader = new FileReader(); 48 | reader.onload = () => { 49 | const result = reader.result as string; 50 | // Remove data URL prefix to get just the base64 data 51 | const base64 = result.split(',')[1]; 52 | resolve(base64); 53 | }; 54 | reader.onerror = reject; 55 | reader.readAsDataURL(file); 56 | }); 57 | 58 | // Use ORPC client to upload profile picture 59 | const result = await orpc.profile.uploadProfilePicture.call({ 60 | userId: userId, 61 | filename: file.name, 62 | contentType: file.type, 63 | fileData: base64Data, 64 | }); 65 | 66 | if (result.success) { 67 | toast.success("Profile picture uploaded successfully!"); 68 | onUploadSuccess?.(result.imageUrl); 69 | } else { 70 | toast.error("Failed to upload profile picture"); 71 | } 72 | } catch (error) { 73 | console.error('Upload error:', error); 74 | toast.error('Failed to upload profile picture. Please try again.'); 75 | } finally { 76 | setIsUploading(false); 77 | } 78 | }; 79 | 80 | const handleDrag = (e: React.DragEvent) => { 81 | e.preventDefault(); 82 | e.stopPropagation(); 83 | if (e.type === "dragenter" || e.type === "dragover") { 84 | setDragActive(true); 85 | } else if (e.type === "dragleave") { 86 | setDragActive(false); 87 | } 88 | }; 89 | 90 | const handleDrop = (e: React.DragEvent) => { 91 | e.preventDefault(); 92 | e.stopPropagation(); 93 | setDragActive(false); 94 | 95 | if (e.dataTransfer.files && e.dataTransfer.files[0]) { 96 | handleFileUpload(e.dataTransfer.files[0]); 97 | } 98 | }; 99 | 100 | const handleFileInput = (e: React.ChangeEvent) => { 101 | if (e.target.files && e.target.files[0]) { 102 | handleFileUpload(e.target.files[0]); 103 | } 104 | }; 105 | 106 | const handleButtonClick = () => { 107 | fileInputRef.current?.click(); 108 | }; 109 | 110 | return ( 111 | 112 | 113 | 114 | 115 | Profile Picture 116 | 117 | 118 | Upload a profile picture to personalize your account 119 | 120 | 121 | 122 | {/* Current Profile Picture Display */} 123 | {currentImageUrl && ( 124 |
125 | Current profile picture 130 |
131 |

Current Picture

132 |

133 | Your profile picture is set 134 |

135 |
136 |
137 | )} 138 | 139 | {/* Upload Area */} 140 |
151 | 158 | 159 |
160 | 161 |
162 |

163 | {isUploading ? "Uploading..." : "Drop your image here"} 164 |

165 |

166 | or click to browse 167 |

168 |
169 |
170 | 171 | 188 |
189 | 190 | {/* File Requirements */} 191 |
192 |

• Supported formats: JPG, PNG, GIF, WebP

193 |

• Maximum file size: 5MB

194 |

• Recommended size: 400x400 pixels

195 |
196 |
197 |
198 | ); 199 | } -------------------------------------------------------------------------------- /apps/web/src/routes/profile.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | import { authClient } from "@/lib/auth-client"; 3 | import { useNavigate } from "@tanstack/react-router"; 4 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 5 | import { Button } from "@/components/ui/button"; 6 | import { Badge } from "@/components/ui/badge"; 7 | import { Skeleton } from "@/components/ui/skeleton"; 8 | import { User, Mail, Calendar, Shield } from "lucide-react"; 9 | import { toast } from "sonner"; 10 | import ProfilePictureUpload from "@/components/profile-picture-upload"; 11 | import { createFileRoute } from "@tanstack/react-router"; 12 | import { orpc } from "@/utils/orpc"; 13 | 14 | export const Route = createFileRoute('/profile')({ 15 | component: ProfilePage, 16 | }); 17 | 18 | function ProfilePage() { 19 | const { data: session, isPending } = authClient.useSession(); 20 | const navigate = useNavigate(); 21 | const [profilePictureUrl, setProfilePictureUrl] = useState(null); 22 | const [isLoadingProfile, setIsLoadingProfile] = useState(true); 23 | 24 | useEffect(() => { 25 | if (!session?.user?.id) return; 26 | 27 | const loadUserProfile = async () => { 28 | try { 29 | const userProfile = await orpc.profile.getUserProfile.call({ 30 | userId: session.user.id, 31 | }); 32 | 33 | if (userProfile?.profilePictureUrl) { 34 | setProfilePictureUrl(userProfile.profilePictureUrl); 35 | } 36 | 37 | return userProfile; 38 | } catch (error) { 39 | console.error('Error loading user profile:', error); 40 | toast.error('Failed to load profile information'); 41 | } finally { 42 | setIsLoadingProfile(false); 43 | } 44 | }; 45 | 46 | loadUserProfile(); 47 | }, [session?.user?.id]); 48 | 49 | if (isPending || isLoadingProfile) { 50 | return ( 51 |
52 |
53 | 54 |
55 | 56 | 57 |
58 |
59 |
60 | ); 61 | } 62 | 63 | if (!session) { 64 | navigate({ to: "/login" }); 65 | return null; 66 | } 67 | 68 | const formatDate = (dateString: string | Date) => { 69 | const date = typeof dateString === 'string' ? new Date(dateString) : dateString; 70 | return date.toLocaleDateString('en-US', { 71 | year: 'numeric', 72 | month: 'long', 73 | day: 'numeric' 74 | }); 75 | }; 76 | 77 | return ( 78 |
79 |
80 |
81 |

Profile

82 |

83 | Manage your account settings and profile information 84 |

85 |
86 | 87 |
88 | {/* Profile Picture Upload */} 89 | { 93 | setProfilePictureUrl(imageUrl); 94 | toast.success('Profile picture updated successfully!'); 95 | }} 96 | /> 97 | 98 | {/* User Information */} 99 | 100 | 101 | 102 | 103 | Account Information 104 | 105 | 106 | Your account details and preferences 107 | 108 | 109 | 110 |
111 |
112 | 113 |
114 |

Name

115 |

{session.user.name}

116 |
117 |
118 | 119 |
120 | 121 |
122 |

Email

123 |

{session.user.email}

124 |
125 |
126 | 127 |
128 | 129 |
130 |

Member Since

131 |

132 | {formatDate(session.user.createdAt)} 133 |

134 |
135 |
136 | 137 |
138 | 139 |
140 |

Account Type

141 |
142 | 143 | User 144 | 145 |
146 |
147 |
148 |
149 | 150 |
151 | 166 |
167 |
168 |
169 |
170 | 171 | {/* Quick Actions */} 172 | 173 | 174 | Quick Actions 175 | 176 | Access your most used features 177 | 178 | 179 | 180 |
181 | 192 | 193 | 204 |
205 |
206 |
207 |
208 |
209 | ); 210 | } -------------------------------------------------------------------------------- /apps/server/src/routers/todo.ts: -------------------------------------------------------------------------------- 1 | import { desc, eq } from "drizzle-orm"; 2 | import z from "zod"; 3 | import { todo } from "../db/schema/todo"; 4 | import { publicProcedure } from "../lib/orpc"; 5 | import { uploadImageToBinding, getImageUrlFromBinding, generateImageKey } from "../lib/r2"; 6 | import { broadcastSystemNotification } from "../lib/broadcast"; 7 | import { createDatabaseConnection } from "../lib/db-factory"; 8 | import type { Env } from "../types/global"; 9 | 10 | export const todoRouter = { 11 | getAll: publicProcedure.handler(async ({ context }) => { 12 | const db = createDatabaseConnection(); 13 | return await db.select().from(todo); 14 | }), 15 | 16 | getAllWithImages: publicProcedure.handler(async ({ context }) => { 17 | try { 18 | console.log('getAllWithImages called'); 19 | const db = createDatabaseConnection(); 20 | const todos = await db.select().from(todo).orderBy(desc(todo.createdAt)); 21 | console.log('Todos fetched:', todos.length); 22 | 23 | // Use R2 binding for image processing 24 | const env = context.env as Env; 25 | 26 | // Generate URLs for todos with images 27 | const todosWithImages = await Promise.all( 28 | todos.map(async (todo) => { 29 | if (todo.imageUrl && todo.imageUrl.startsWith('todos/')) { 30 | try { 31 | console.log(`Generating URL for todo ${todo.id} with key: ${todo.imageUrl}`); 32 | // Generate URL from the stored R2 key using binding 33 | const serverUrl = context.req?.url ? new URL(context.req.url).origin : 'http://localhost:8787'; 34 | const imageUrl = `${serverUrl}/api/images/${todo.imageUrl}`; 35 | console.log(`Generated URL for todo ${todo.id}: ${imageUrl.substring(0, 50)}...`); 36 | return { ...todo, imageUrl }; 37 | } catch (error) { 38 | console.error(`Failed to generate URL for todo ${todo.id}:`, error); 39 | return { ...todo, imageUrl: null }; 40 | } 41 | } 42 | return todo; // Return as-is if no image or not an R2 key 43 | }) 44 | ); 45 | 46 | console.log('Returning todos with images:', todosWithImages.length); 47 | return todosWithImages; 48 | } catch (error) { 49 | console.error('Error in getAllWithImages:', error); 50 | // Fallback to regular getAll if there's an error 51 | console.log('Falling back to regular getAll'); 52 | const db = createDatabaseConnection(); 53 | return await db.select().from(todo); 54 | } 55 | }), 56 | 57 | create: publicProcedure 58 | .input(z.object({ 59 | text: z.string().min(1), 60 | imageUrl: z.string().optional() 61 | })) 62 | .handler(async ({ input, context }) => { 63 | const db = createDatabaseConnection(); 64 | const result = await db 65 | .insert(todo) 66 | .values({ 67 | text: input.text, 68 | imageUrl: input.imageUrl, 69 | }) 70 | .returning(); 71 | 72 | // Example: Broadcast a notification when a new todo is created 73 | try { 74 | const env = context.env as Env; 75 | await broadcastSystemNotification(env, `New todo created: "${input.text}"`); 76 | } catch (error) { 77 | console.error("Failed to broadcast todo creation:", error); 78 | // Don't fail the todo creation if broadcast fails 79 | } 80 | 81 | return result[0]; // Return the created todo with its ID 82 | }), 83 | 84 | uploadImage: publicProcedure 85 | .input(z.object({ 86 | todoId: z.number(), 87 | filename: z.string(), 88 | contentType: z.string(), 89 | fileData: z.string() // Base64 encoded file data 90 | })) 91 | .handler(async ({ input, context }) => { 92 | console.log('Upload image request:', { 93 | todoId: input.todoId, 94 | filename: input.filename, 95 | contentType: input.contentType, 96 | dataLength: input.fileData.length 97 | }); 98 | 99 | const env = context.env as Env; 100 | const db = createDatabaseConnection(); 101 | 102 | try { 103 | // Decode base64 file data 104 | const fileBuffer = Uint8Array.from(atob(input.fileData), c => c.charCodeAt(0)); 105 | console.log('Base64 decoded, buffer length:', fileBuffer.length); 106 | 107 | const key = generateImageKey(input.todoId, input.filename); 108 | console.log('Generated key:', key); 109 | 110 | await uploadImageToBinding(env.TODO_IMAGES, key, fileBuffer, input.contentType); 111 | console.log('Image uploaded to R2 successfully'); 112 | 113 | // Update the todo with the image key (stored as imageUrl) 114 | const updateResult = await db 115 | .update(todo) 116 | .set({ imageUrl: key }) 117 | .where(eq(todo.id, input.todoId)); 118 | 119 | console.log('Todo updated with image key:', updateResult); 120 | 121 | return { imageUrl: key }; 122 | } catch (error) { 123 | console.error('Error in uploadImage handler:', error); 124 | throw error; 125 | } 126 | }), 127 | 128 | toggle: publicProcedure 129 | .input(z.object({ id: z.number(), completed: z.boolean() })) 130 | .handler(async ({ input, context }) => { 131 | const db = createDatabaseConnection(); 132 | return await db 133 | .update(todo) 134 | .set({ completed: input.completed }) 135 | .where(eq(todo.id, input.id)); 136 | }), 137 | 138 | delete: publicProcedure 139 | .input(z.object({ id: z.number() })) 140 | .handler(async ({ input, context }) => { 141 | const db = createDatabaseConnection(); 142 | const result = await db.delete(todo).where(eq(todo.id, input.id)); 143 | 144 | // Example: Broadcast a notification when a todo is deleted 145 | try { 146 | const env = context.env as Env; 147 | await broadcastSystemNotification(env, `Todo deleted with ID: ${input.id}`); 148 | } catch (error) { 149 | console.error("Failed to broadcast todo deletion:", error); 150 | // Don't fail the todo deletion if broadcast fails 151 | } 152 | 153 | return result; 154 | }), 155 | 156 | // Debug endpoint to test R2 connection 157 | testR2: publicProcedure.handler(async ({ context }) => { 158 | try { 159 | const env = context.env as Env; 160 | console.log('R2 binding check:', { 161 | hasBinding: !!env.TODO_IMAGES 162 | }); 163 | 164 | // Test the R2 binding 165 | console.log('R2 binding available'); 166 | 167 | return { 168 | success: true, 169 | message: 'R2 binding available', 170 | hasBinding: !!env.TODO_IMAGES 171 | }; 172 | } catch (error) { 173 | console.error('R2 test failed:', error); 174 | return { 175 | success: false, 176 | error: error instanceof Error ? error.message : 'Unknown error', 177 | message: 'R2 client creation failed' 178 | }; 179 | } 180 | }), 181 | 182 | // Test getAllWithImages specifically 183 | testGetAllWithImages: publicProcedure.handler(async ({ context }) => { 184 | try { 185 | console.log('Testing getAllWithImages...'); 186 | const db = createDatabaseConnection(); 187 | const todos = await db.select().from(todo); 188 | console.log('Found todos:', todos.length); 189 | 190 | const env = context.env as Env; 191 | const hasR2Credentials = env.CLOUDFLARE_ACCOUNT_ID && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY; 192 | 193 | return { 194 | success: true, 195 | todosCount: todos.length, 196 | hasR2Credentials, 197 | todosWithImages: todos.filter(t => t.imageUrl && t.imageUrl.startsWith('todos/')).length 198 | }; 199 | } catch (error) { 200 | console.error('testGetAllWithImages failed:', error); 201 | return { 202 | success: false, 203 | error: error instanceof Error ? error.message : 'Unknown error' 204 | }; 205 | } 206 | }), 207 | }; 208 | 209 | -------------------------------------------------------------------------------- /apps/web/src/routes/todos.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from "@/components/ui/card"; 9 | import { Checkbox } from "@/components/ui/checkbox"; 10 | import { Input } from "@/components/ui/input"; 11 | import { Label } from "@/components/ui/label"; 12 | import { createFileRoute } from "@tanstack/react-router"; 13 | import { Loader2, Trash2, Upload, X } from "lucide-react"; 14 | import { useState } from "react"; 15 | 16 | import { orpc } from "@/utils/orpc"; 17 | import { useQuery, useMutation } from "@tanstack/react-query"; 18 | import { useTodoMutations } from "@/hooks/useTodoMutations"; 19 | import { useImageHandling } from "@/hooks/useImageHandling"; 20 | 21 | export const Route = createFileRoute("/todos")({ 22 | component: TodosRoute, 23 | }); 24 | 25 | function TodosRoute() { 26 | const [newTodoText, setNewTodoText] = useState(""); 27 | 28 | const todos = useQuery(orpc.todo.getAllWithImages.queryOptions()); 29 | const { createTodoMutation, toggleTodoMutation, deleteTodoMutation } = useTodoMutations(); 30 | const { 31 | selectedImage, 32 | imagePreview, 33 | fileInputRef, 34 | handleImageSelect, 35 | handleRemoveImage, 36 | clearImage 37 | } = useImageHandling(); 38 | 39 | const handleAddTodo = async (e: React.FormEvent) => { 40 | e.preventDefault(); 41 | if (newTodoText.trim() && !createTodoMutation.isPending) { 42 | console.log('Creating todo with image:', { text: newTodoText, hasImage: !!selectedImage }); 43 | 44 | try { 45 | await createTodoMutation.mutateAsync({ 46 | text: newTodoText.trim(), 47 | imageFile: selectedImage || undefined, 48 | }); 49 | 50 | // Clear form after successful creation 51 | setNewTodoText(""); 52 | clearImage(); 53 | todos.refetch(); 54 | } catch (error) { 55 | console.error('Failed to create todo:', error); 56 | alert(error instanceof Error ? error.message : 'Failed to create todo'); 57 | } 58 | } 59 | }; 60 | 61 | const handleToggleTodo = (id: number, completed: boolean) => { 62 | toggleTodoMutation.mutate({ id, completed: !completed }, { 63 | onSuccess: () => { todos.refetch() } 64 | }); 65 | }; 66 | 67 | const handleDeleteTodo = (id: number) => { 68 | deleteTodoMutation.mutate({ id }, { 69 | onSuccess: () => { todos.refetch() } 70 | }); 71 | }; 72 | 73 | return ( 74 |
75 | 76 | 77 | Todo List 78 | Manage your tasks efficiently 79 | 80 | 81 |
82 |
83 | setNewTodoText(e.target.value)} 86 | placeholder="Add a new task..." 87 | disabled={createTodoMutation.isPending} 88 | /> 89 | 99 |
100 | 101 |
102 | 105 |
106 | 115 | 124 |
125 | 126 | {imagePreview && ( 127 |
128 | Preview 133 | 142 |
143 | )} 144 |
145 |
146 | 147 | {todos.isLoading ? ( 148 |
149 | 150 |
151 | ) : (todos.data as any[])?.length === 0 ? ( 152 |

153 | No todos yet. Add one above! 154 |

155 | ) : ( 156 |
    157 | {(todos.data as any[])?.map((todo: any) => ( 158 |
  • 162 |
    163 |
    164 | 167 | handleToggleTodo(todo.id, todo.completed) 168 | } 169 | id={`todo-${todo.id}`} 170 | className="mt-1" 171 | /> 172 |
    173 | 179 | {todo.imageUrl && ( 180 |
    181 | Todo attachment 186 |
    187 | )} 188 |
    189 |
    190 | 199 |
    200 |
  • 201 | ))} 202 |
203 | )} 204 |
205 |
206 |
207 | ); 208 | } 209 | -------------------------------------------------------------------------------- /apps/web/src/hooks/useOfflineSync.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect, useCallback } from 'react'; 2 | 3 | export interface OfflineTodo { 4 | id: string; 5 | text: string; 6 | completed: boolean; 7 | imageUrl?: string | null; 8 | imageFile?: File | null; 9 | status: 'synced' | 'pending' | 'syncing' | 'error'; 10 | error?: string; 11 | localId?: string; 12 | serverId?: number; 13 | createdAt: number; 14 | } 15 | 16 | export interface QueuedAction { 17 | id: string; 18 | type: 'create' | 'update' | 'delete'; 19 | todoId: string; 20 | data?: any; 21 | retryCount: number; 22 | lastAttempt?: number; 23 | } 24 | 25 | const TODOS_KEY = 'offline-todos'; 26 | const SYNC_QUEUE_KEY = 'sync-queue'; 27 | 28 | export function useOfflineSync() { 29 | const [todos, setTodos] = useState([]); 30 | const [syncQueue, setSyncQueue] = useState([]); 31 | const [isOnline, setIsOnline] = useState(navigator.onLine); 32 | const [isSyncing, setIsSyncing] = useState(false); 33 | 34 | // Load from localStorage 35 | useEffect(() => { 36 | const savedTodos = localStorage.getItem(TODOS_KEY); 37 | const savedQueue = localStorage.getItem(SYNC_QUEUE_KEY); 38 | 39 | if (savedTodos) { 40 | try { 41 | setTodos(JSON.parse(savedTodos)); 42 | } catch (error) { 43 | console.error('Failed to parse saved todos:', error); 44 | } 45 | } 46 | 47 | if (savedQueue) { 48 | try { 49 | setSyncQueue(JSON.parse(savedQueue)); 50 | } catch (error) { 51 | console.error('Failed to parse sync queue:', error); 52 | } 53 | } 54 | }, []); 55 | 56 | // Save to localStorage 57 | useEffect(() => { 58 | try { 59 | localStorage.setItem(TODOS_KEY, JSON.stringify(todos)); 60 | } catch (error) { 61 | console.error('Failed to save todos:', error); 62 | } 63 | }, [todos]); 64 | 65 | useEffect(() => { 66 | try { 67 | localStorage.setItem(SYNC_QUEUE_KEY, JSON.stringify(syncQueue)); 68 | } catch (error) { 69 | console.error('Failed to save sync queue:', error); 70 | } 71 | }, [syncQueue]); 72 | 73 | // Online/offline detection 74 | useEffect(() => { 75 | const handleOnline = () => setIsOnline(true); 76 | const handleOffline = () => setIsOnline(false); 77 | 78 | window.addEventListener('online', handleOnline); 79 | window.addEventListener('offline', handleOffline); 80 | 81 | return () => { 82 | window.removeEventListener('online', handleOnline); 83 | window.removeEventListener('offline', handleOffline); 84 | }; 85 | }, []); 86 | 87 | // Generate unique local ID 88 | const generateLocalId = useCallback(() => { 89 | return `local_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; 90 | }, []); 91 | 92 | // Add todo (offline-first) 93 | const addTodo = useCallback(async (text: string, imageFile?: File) => { 94 | const localId = generateLocalId(); 95 | const newTodo: OfflineTodo = { 96 | id: localId, 97 | text: text.trim(), 98 | completed: false, 99 | imageFile, 100 | status: isOnline ? 'syncing' : 'pending', 101 | localId, 102 | createdAt: Date.now(), 103 | }; 104 | 105 | // Add to local state immediately 106 | setTodos(prev => [newTodo, ...prev]); 107 | 108 | // Queue for sync 109 | const queueAction: QueuedAction = { 110 | id: generateLocalId(), 111 | type: 'create', 112 | todoId: localId, 113 | data: { text, image: imageFile }, 114 | retryCount: 0, 115 | }; 116 | 117 | setSyncQueue(prev => [...prev, queueAction]); 118 | 119 | return newTodo; 120 | }, [isOnline, generateLocalId]); 121 | 122 | // Toggle todo completion 123 | const toggleTodo = useCallback(async (todoId: string) => { 124 | const todo = todos.find(t => t.id === todoId); 125 | if (!todo) return; 126 | 127 | const updatedTodo: OfflineTodo = { 128 | ...todo, 129 | completed: !todo.completed, 130 | status: isOnline ? 'syncing' : 'pending', 131 | }; 132 | 133 | setTodos(prev => prev.map(t => t.id === todoId ? updatedTodo : t)); 134 | 135 | // Queue for sync 136 | const queueAction: QueuedAction = { 137 | id: generateLocalId(), 138 | type: 'update', 139 | todoId, 140 | data: { 141 | completed: updatedTodo.completed, 142 | serverId: todo.serverId, 143 | }, 144 | retryCount: 0, 145 | }; 146 | 147 | setSyncQueue(prev => [...prev, queueAction]); 148 | 149 | return updatedTodo; 150 | }, [todos, isOnline, generateLocalId]); 151 | 152 | // Delete todo 153 | const deleteTodo = useCallback(async (todoId: string) => { 154 | const todo = todos.find(t => t.id === todoId); 155 | if (!todo) return; 156 | 157 | // Remove from local state 158 | setTodos(prev => prev.filter(t => t.id !== todoId)); 159 | 160 | // Only queue for deletion if it was synced to server 161 | if (todo.serverId) { 162 | const queueAction: QueuedAction = { 163 | id: generateLocalId(), 164 | type: 'delete', 165 | todoId, 166 | data: { serverId: todo.serverId }, 167 | retryCount: 0, 168 | }; 169 | 170 | setSyncQueue(prev => [...prev, queueAction]); 171 | } 172 | }, [todos, generateLocalId]); 173 | 174 | // Sync pending actions 175 | const syncPendingActions = useCallback(async () => { 176 | if (isSyncing || !isOnline || syncQueue.length === 0) return; 177 | 178 | setIsSyncing(true); 179 | 180 | try { 181 | for (const action of syncQueue) { 182 | if (action.retryCount < 3) { 183 | await syncAction(action); 184 | await new Promise(resolve => setTimeout(resolve, 100)); 185 | } 186 | } 187 | } finally { 188 | setIsSyncing(false); 189 | } 190 | }, [isSyncing, isOnline, syncQueue]); 191 | 192 | // Sync individual action 193 | const syncAction = async (action: QueuedAction) => { 194 | try { 195 | switch (action.type) { 196 | case 'create': 197 | const formData = new FormData(); 198 | formData.append('text', action.data.text); 199 | if (action.data.image) { 200 | formData.append('image', action.data.image); 201 | } 202 | 203 | const response = await fetch(`${import.meta.env.VITE_SERVER_URL}/todos/create-with-image`, { 204 | method: 'POST', 205 | body: formData, 206 | credentials: 'include', 207 | }); 208 | 209 | if (response.ok) { 210 | const serverTodo = await response.json(); 211 | 212 | setTodos(prev => prev.map(t => 213 | t.id === action.todoId 214 | ? { ...t, status: 'synced', serverId: serverTodo.id, imageUrl: serverTodo.imageUrl } 215 | : t 216 | )); 217 | 218 | setSyncQueue(prev => prev.filter(a => a.id !== action.id)); 219 | } else { 220 | throw new Error('Failed to create todo'); 221 | } 222 | break; 223 | 224 | // Add other sync cases here... 225 | } 226 | } catch (error) { 227 | console.error('Sync failed:', error); 228 | 229 | if (action.type === 'create') { 230 | setTodos(prev => prev.map(t => 231 | t.id === action.todoId 232 | ? { ...t, status: 'error', error: error instanceof Error ? error.message : 'Sync failed' } 233 | : t 234 | )); 235 | } 236 | 237 | setSyncQueue(prev => prev.map(a => 238 | a.id === action.id 239 | ? { ...a, retryCount: a.retryCount + 1, lastAttempt: Date.now() } 240 | : a 241 | )); 242 | } 243 | }; 244 | 245 | // Auto-sync when coming online 246 | useEffect(() => { 247 | if (isOnline && syncQueue.length > 0 && !isSyncing) { 248 | syncPendingActions(); 249 | } 250 | }, [isOnline, syncQueue.length, isSyncing, syncPendingActions]); 251 | 252 | return { 253 | todos, 254 | setTodos, 255 | syncQueue, 256 | isOnline, 257 | isSyncing, 258 | addTodo, 259 | toggleTodo, 260 | deleteTodo, 261 | syncPendingActions, 262 | getPendingCount: () => syncQueue.length, 263 | }; 264 | } -------------------------------------------------------------------------------- /apps/web/src/routeTree.gen.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | // @ts-nocheck 4 | 5 | // noinspection JSUnusedGlobalSymbols 6 | 7 | // This file was automatically generated by TanStack Router. 8 | // You should NOT make any changes in this file as it will be overwritten. 9 | // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. 10 | 11 | import { Route as rootRouteImport } from './routes/__root' 12 | import { Route as TodosOfflineRouteImport } from './routes/todos-offline' 13 | import { Route as TodosRouteImport } from './routes/todos' 14 | import { Route as PublicChatRouteImport } from './routes/public-chat' 15 | import { Route as ProfileRouteImport } from './routes/profile' 16 | import { Route as LoginRouteImport } from './routes/login' 17 | import { Route as InstallPwaRouteImport } from './routes/install-pwa' 18 | import { Route as HealthRouteImport } from './routes/health' 19 | import { Route as DashboardRouteImport } from './routes/dashboard' 20 | import { Route as AdminChatRouteImport } from './routes/admin-chat' 21 | import { Route as IndexRouteImport } from './routes/index' 22 | 23 | const TodosOfflineRoute = TodosOfflineRouteImport.update({ 24 | id: '/todos-offline', 25 | path: '/todos-offline', 26 | getParentRoute: () => rootRouteImport, 27 | } as any) 28 | const TodosRoute = TodosRouteImport.update({ 29 | id: '/todos', 30 | path: '/todos', 31 | getParentRoute: () => rootRouteImport, 32 | } as any) 33 | const PublicChatRoute = PublicChatRouteImport.update({ 34 | id: '/public-chat', 35 | path: '/public-chat', 36 | getParentRoute: () => rootRouteImport, 37 | } as any) 38 | const ProfileRoute = ProfileRouteImport.update({ 39 | id: '/profile', 40 | path: '/profile', 41 | getParentRoute: () => rootRouteImport, 42 | } as any) 43 | const LoginRoute = LoginRouteImport.update({ 44 | id: '/login', 45 | path: '/login', 46 | getParentRoute: () => rootRouteImport, 47 | } as any) 48 | const InstallPwaRoute = InstallPwaRouteImport.update({ 49 | id: '/install-pwa', 50 | path: '/install-pwa', 51 | getParentRoute: () => rootRouteImport, 52 | } as any) 53 | const HealthRoute = HealthRouteImport.update({ 54 | id: '/health', 55 | path: '/health', 56 | getParentRoute: () => rootRouteImport, 57 | } as any) 58 | const DashboardRoute = DashboardRouteImport.update({ 59 | id: '/dashboard', 60 | path: '/dashboard', 61 | getParentRoute: () => rootRouteImport, 62 | } as any) 63 | const AdminChatRoute = AdminChatRouteImport.update({ 64 | id: '/admin-chat', 65 | path: '/admin-chat', 66 | getParentRoute: () => rootRouteImport, 67 | } as any) 68 | const IndexRoute = IndexRouteImport.update({ 69 | id: '/', 70 | path: '/', 71 | getParentRoute: () => rootRouteImport, 72 | } as any) 73 | 74 | export interface FileRoutesByFullPath { 75 | '/': typeof IndexRoute 76 | '/admin-chat': typeof AdminChatRoute 77 | '/dashboard': typeof DashboardRoute 78 | '/health': typeof HealthRoute 79 | '/install-pwa': typeof InstallPwaRoute 80 | '/login': typeof LoginRoute 81 | '/profile': typeof ProfileRoute 82 | '/public-chat': typeof PublicChatRoute 83 | '/todos': typeof TodosRoute 84 | '/todos-offline': typeof TodosOfflineRoute 85 | } 86 | export interface FileRoutesByTo { 87 | '/': typeof IndexRoute 88 | '/admin-chat': typeof AdminChatRoute 89 | '/dashboard': typeof DashboardRoute 90 | '/health': typeof HealthRoute 91 | '/install-pwa': typeof InstallPwaRoute 92 | '/login': typeof LoginRoute 93 | '/profile': typeof ProfileRoute 94 | '/public-chat': typeof PublicChatRoute 95 | '/todos': typeof TodosRoute 96 | '/todos-offline': typeof TodosOfflineRoute 97 | } 98 | export interface FileRoutesById { 99 | __root__: typeof rootRouteImport 100 | '/': typeof IndexRoute 101 | '/admin-chat': typeof AdminChatRoute 102 | '/dashboard': typeof DashboardRoute 103 | '/health': typeof HealthRoute 104 | '/install-pwa': typeof InstallPwaRoute 105 | '/login': typeof LoginRoute 106 | '/profile': typeof ProfileRoute 107 | '/public-chat': typeof PublicChatRoute 108 | '/todos': typeof TodosRoute 109 | '/todos-offline': typeof TodosOfflineRoute 110 | } 111 | export interface FileRouteTypes { 112 | fileRoutesByFullPath: FileRoutesByFullPath 113 | fullPaths: 114 | | '/' 115 | | '/admin-chat' 116 | | '/dashboard' 117 | | '/health' 118 | | '/install-pwa' 119 | | '/login' 120 | | '/profile' 121 | | '/public-chat' 122 | | '/todos' 123 | | '/todos-offline' 124 | fileRoutesByTo: FileRoutesByTo 125 | to: 126 | | '/' 127 | | '/admin-chat' 128 | | '/dashboard' 129 | | '/health' 130 | | '/install-pwa' 131 | | '/login' 132 | | '/profile' 133 | | '/public-chat' 134 | | '/todos' 135 | | '/todos-offline' 136 | id: 137 | | '__root__' 138 | | '/' 139 | | '/admin-chat' 140 | | '/dashboard' 141 | | '/health' 142 | | '/install-pwa' 143 | | '/login' 144 | | '/profile' 145 | | '/public-chat' 146 | | '/todos' 147 | | '/todos-offline' 148 | fileRoutesById: FileRoutesById 149 | } 150 | export interface RootRouteChildren { 151 | IndexRoute: typeof IndexRoute 152 | AdminChatRoute: typeof AdminChatRoute 153 | DashboardRoute: typeof DashboardRoute 154 | HealthRoute: typeof HealthRoute 155 | InstallPwaRoute: typeof InstallPwaRoute 156 | LoginRoute: typeof LoginRoute 157 | ProfileRoute: typeof ProfileRoute 158 | PublicChatRoute: typeof PublicChatRoute 159 | TodosRoute: typeof TodosRoute 160 | TodosOfflineRoute: typeof TodosOfflineRoute 161 | } 162 | 163 | declare module '@tanstack/react-router' { 164 | interface FileRoutesByPath { 165 | '/todos-offline': { 166 | id: '/todos-offline' 167 | path: '/todos-offline' 168 | fullPath: '/todos-offline' 169 | preLoaderRoute: typeof TodosOfflineRouteImport 170 | parentRoute: typeof rootRouteImport 171 | } 172 | '/todos': { 173 | id: '/todos' 174 | path: '/todos' 175 | fullPath: '/todos' 176 | preLoaderRoute: typeof TodosRouteImport 177 | parentRoute: typeof rootRouteImport 178 | } 179 | '/public-chat': { 180 | id: '/public-chat' 181 | path: '/public-chat' 182 | fullPath: '/public-chat' 183 | preLoaderRoute: typeof PublicChatRouteImport 184 | parentRoute: typeof rootRouteImport 185 | } 186 | '/profile': { 187 | id: '/profile' 188 | path: '/profile' 189 | fullPath: '/profile' 190 | preLoaderRoute: typeof ProfileRouteImport 191 | parentRoute: typeof rootRouteImport 192 | } 193 | '/login': { 194 | id: '/login' 195 | path: '/login' 196 | fullPath: '/login' 197 | preLoaderRoute: typeof LoginRouteImport 198 | parentRoute: typeof rootRouteImport 199 | } 200 | '/install-pwa': { 201 | id: '/install-pwa' 202 | path: '/install-pwa' 203 | fullPath: '/install-pwa' 204 | preLoaderRoute: typeof InstallPwaRouteImport 205 | parentRoute: typeof rootRouteImport 206 | } 207 | '/health': { 208 | id: '/health' 209 | path: '/health' 210 | fullPath: '/health' 211 | preLoaderRoute: typeof HealthRouteImport 212 | parentRoute: typeof rootRouteImport 213 | } 214 | '/dashboard': { 215 | id: '/dashboard' 216 | path: '/dashboard' 217 | fullPath: '/dashboard' 218 | preLoaderRoute: typeof DashboardRouteImport 219 | parentRoute: typeof rootRouteImport 220 | } 221 | '/admin-chat': { 222 | id: '/admin-chat' 223 | path: '/admin-chat' 224 | fullPath: '/admin-chat' 225 | preLoaderRoute: typeof AdminChatRouteImport 226 | parentRoute: typeof rootRouteImport 227 | } 228 | '/': { 229 | id: '/' 230 | path: '/' 231 | fullPath: '/' 232 | preLoaderRoute: typeof IndexRouteImport 233 | parentRoute: typeof rootRouteImport 234 | } 235 | } 236 | } 237 | 238 | const rootRouteChildren: RootRouteChildren = { 239 | IndexRoute: IndexRoute, 240 | AdminChatRoute: AdminChatRoute, 241 | DashboardRoute: DashboardRoute, 242 | HealthRoute: HealthRoute, 243 | InstallPwaRoute: InstallPwaRoute, 244 | LoginRoute: LoginRoute, 245 | ProfileRoute: ProfileRoute, 246 | PublicChatRoute: PublicChatRoute, 247 | TodosRoute: TodosRoute, 248 | TodosOfflineRoute: TodosOfflineRoute, 249 | } 250 | export const routeTree = rootRouteImport 251 | ._addFileChildren(rootRouteChildren) 252 | ._addFileTypes() 253 | -------------------------------------------------------------------------------- /DEPLOY.md: -------------------------------------------------------------------------------- 1 | # Future Stack Deployment Guide 2 | 3 | This guide covers deploying both the backend (Cloudflare Workers) and frontend (static hosting) for production. 4 | 5 | ## 🚀 Backend Deployment (Cloudflare Workers) 6 | 7 | ### Prerequisites 8 | 9 | 1. **Cloudflare Account** 10 | - Sign up at [cloudflare.com](https://cloudflare.com) 11 | - Install Wrangler CLI: `npm install -g wrangler` 12 | - Login: `wrangler login` 13 | 14 | 2. **Production Database** 15 | - Use [Neon.tech](https://neon.tech) (recommended) or any PostgreSQL provider 16 | - Ensure it accepts external connections 17 | 18 | ### Step 1: Set Up Cloudflare R2 Bucket 19 | 20 | ```bash 21 | # Create R2 bucket for images 22 | wrangler r2 bucket create future-stack-todo-images 23 | 24 | # Verify bucket was created 25 | wrangler r2 bucket list 26 | ``` 27 | 28 | ### Step 2: Configure Durable Objects 29 | 30 | The Durable Object is already configured in `wrangler.jsonc`. Verify the configuration: 31 | 32 | ```json 33 | { 34 | "durable_objects": { 35 | "bindings": [ 36 | { 37 | "name": "ADMIN_CHAT", 38 | "class_name": "AdminChat", 39 | "script_name": "future-stack-server" 40 | } 41 | ] 42 | } 43 | } 44 | ``` 45 | 46 | ### Step 3: Set Production Secrets 47 | 48 | **Never put secrets in `wrangler.jsonc` - use Wrangler secrets:** 49 | 50 | ```bash 51 | # Navigate to server directory 52 | cd apps/server 53 | 54 | # Set authentication secrets 55 | wrangler secret put BETTER_AUTH_SECRET 56 | # Enter: Your secure random string (generate with: openssl rand -base64 32) 57 | 58 | wrangler secret put BETTER_AUTH_URL 59 | # Enter: https://your-api-domain.your-subdomain.workers.dev 60 | 61 | # Set database secrets 62 | wrangler secret put DATABASE_URL 63 | # Enter: Your production PostgreSQL connection string 64 | 65 | # Set Cloudflare R2 secrets 66 | wrangler secret put CLOUDFLARE_ACCOUNT_ID 67 | # Enter: Your Cloudflare Account ID (found in dashboard sidebar) 68 | 69 | wrangler secret put R2_ACCESS_KEY_ID 70 | # Enter: Your R2 API token access key 71 | 72 | wrangler secret put R2_SECRET_ACCESS_KEY 73 | # Enter: Your R2 API token secret key 74 | ``` 75 | 76 | ### Step 4: Update Public Variables 77 | 78 | Edit `apps/server/wrangler.jsonc` and update the `vars` section: 79 | 80 | ```json 81 | { 82 | "vars": { 83 | "NODE_ENV": "production", 84 | "CORS_ORIGIN": "https://your-frontend-domain.com" 85 | } 86 | } 87 | ``` 88 | 89 | ### Step 5: Deploy Backend 90 | 91 | ```bash 92 | # Generate database schema (if not done) 93 | bun run db:generate 94 | 95 | # Build and deploy 96 | wrangler deploy 97 | 98 | # The deployment will output your Worker URL: 99 | # https://future-stack-server.your-subdomain.workers.dev 100 | ``` 101 | 102 | ### Step 6: Run Database Migrations 103 | 104 | ```bash 105 | # Push schema to production database 106 | bun run db:push 107 | 108 | # Create admin user manually in your database 109 | # Connect to your production database and run: 110 | # UPDATE "user" SET is_admin = true WHERE email = 'admin@yourdomain.com'; 111 | ``` 112 | 113 | ## 🌐 Frontend Deployment 114 | 115 | ### Option A: Vercel (Recommended) 116 | 117 | 1. **Connect Repository** 118 | ```bash 119 | # Install Vercel CLI 120 | npm install -g vercel 121 | 122 | # Navigate to web app 123 | cd apps/web 124 | 125 | # Deploy 126 | vercel 127 | ``` 128 | 129 | 2. **Configure Environment Variables in Vercel Dashboard** 130 | - Go to your project settings 131 | - Add environment variable: 132 | - `VITE_API_URL`: `https://your-worker-url.workers.dev` 133 | 134 | 3. **Build Settings** 135 | - Framework Preset: `Vite` 136 | - Build Command: `bun run build` 137 | - Output Directory: `dist` 138 | - Install Command: `bun install` 139 | 140 | ### Option B: Netlify 141 | 142 | 1. **Deploy via Git** 143 | - Connect your GitHub repository 144 | - Set build settings: 145 | - Build command: `cd apps/web && bun run build` 146 | - Publish directory: `apps/web/dist` 147 | 148 | 2. **Environment Variables** 149 | - Add in Netlify dashboard: 150 | - `VITE_API_URL`: `https://your-worker-url.workers.dev` 151 | 152 | ### Option C: Cloudflare Pages 153 | 154 | 1. **Connect Repository** 155 | - Go to Cloudflare Dashboard → Pages 156 | - Connect your GitHub repository 157 | 158 | 2. **Build Settings** 159 | - Framework preset: `None` 160 | - Build command: `cd apps/web && bun install && bun run build` 161 | - Build output directory: `apps/web/dist` 162 | 163 | 3. **Environment Variables** 164 | - Add in Pages settings: 165 | - `VITE_API_URL`: `https://your-worker-url.workers.dev` 166 | 167 | ## 🔧 Post-Deployment Configuration 168 | 169 | ### 1. Update CORS Settings 170 | 171 | Update your backend's CORS_ORIGIN secret: 172 | 173 | ```bash 174 | wrangler secret put CORS_ORIGIN 175 | # Enter: https://your-frontend-domain.com 176 | ``` 177 | 178 | ### 2. Test Deployment 179 | 180 | 1. **Test Authentication** 181 | - Visit your frontend URL 182 | - Create a new account 183 | - Verify login works 184 | 185 | 2. **Test Todo Functionality** 186 | - Create a todo 187 | - Upload an image 188 | - Verify image displays correctly 189 | 190 | 3. **Test Admin Chat** 191 | - Set a user as admin in database 192 | - Access `/admin-chat` route 193 | - Test real-time messaging 194 | 195 | ### 3. Set Up Custom Domain (Optional) 196 | 197 | **For Cloudflare Workers:** 198 | ```bash 199 | # Add custom domain to worker 200 | wrangler route add "api.yourdomain.com/*" future-stack-server 201 | ``` 202 | 203 | **For Frontend:** 204 | - Configure DNS to point to your hosting provider 205 | - Set up SSL certificate (usually automatic) 206 | 207 | ## 🔒 Security Checklist 208 | 209 | - [ ] All secrets stored in Wrangler secrets (not in code) 210 | - [ ] CORS_ORIGIN set to production frontend URL 211 | - [ ] Database uses SSL connections 212 | - [ ] R2 bucket has appropriate access controls 213 | - [ ] Admin users manually verified in database 214 | - [ ] HTTPS enabled on all endpoints 215 | 216 | ## 🐛 Troubleshooting 217 | 218 | ### Common Issues 219 | 220 | **"CORS Error"** 221 | - Verify `CORS_ORIGIN` secret matches your frontend URL exactly 222 | - Check that both HTTP and HTTPS protocols match 223 | 224 | **"Database Connection Failed"** 225 | - Verify `DATABASE_URL` is correct 226 | - Ensure database allows external connections 227 | - Check SSL mode settings 228 | 229 | **"R2 Upload Fails"** 230 | - Confirm `CLOUDFLARE_ACCOUNT_ID` is correct 231 | - Verify R2 API tokens have correct permissions 232 | - Check bucket name matches `wrangler.jsonc` 233 | 234 | **"WebSocket Connection Denied"** 235 | - Ensure user has `is_admin = true` in database 236 | - Verify session cookies are being sent 237 | - Check that authentication is working 238 | 239 | **"Durable Object Deployment Error"** 240 | - Error: `Cannot create binding for class 'AdminChat' that is not exported` 241 | - Ensure `AdminChat` is exported in `src/index.ts` 242 | - Use `export const AdminChat = AdminChatClass;` syntax 243 | - Error: `Cannot create binding... is not currently configured to implement Durable Objects` 244 | - Add migration to `wrangler.jsonc`: 245 | ```json 246 | "migrations": [ 247 | { 248 | "tag": "v1", 249 | "new_sqlite_classes": ["AdminChat"] 250 | } 251 | ] 252 | ``` 253 | - Error: `must create a namespace using a new_sqlite_classes migration` 254 | - Use `new_sqlite_classes` instead of `new_classes` for free plan 255 | 256 | ### Debugging Commands 257 | 258 | ```bash 259 | # View Worker logs 260 | wrangler tail 261 | 262 | # Check R2 bucket contents 263 | wrangler r2 object list future-stack-todo-images 264 | 265 | # Test database connection 266 | bun run db:studio 267 | 268 | # View current secrets (names only) 269 | wrangler secret list 270 | ``` 271 | 272 | ## 📊 Monitoring 273 | 274 | ### Cloudflare Analytics 275 | - Worker performance metrics 276 | - Request volume and errors 277 | - Geographic distribution 278 | 279 | ### Database Monitoring 280 | - Connection pool usage 281 | - Query performance 282 | - Storage usage 283 | 284 | ### R2 Storage 285 | - Upload success rates 286 | - Storage usage 287 | - Bandwidth consumption 288 | 289 | ## 🔄 Updates and Maintenance 290 | 291 | ### Updating Backend 292 | ```bash 293 | cd apps/server 294 | wrangler deploy 295 | ``` 296 | 297 | ### Updating Frontend 298 | - Push changes to your repository 299 | - Most platforms auto-deploy on git push 300 | 301 | ### Database Migrations 302 | ```bash 303 | # Generate new migration 304 | bun run db:generate 305 | 306 | # Apply to production 307 | bun run db:push 308 | ``` 309 | 310 | ## 🚨 Rollback Procedures 311 | 312 | ### Backend Rollback 313 | ```bash 314 | # Deploy previous version 315 | wrangler rollback 316 | ``` 317 | 318 | ### Database Rollback 319 | - Restore from database backup 320 | - Manually revert schema changes if needed 321 | 322 | ### Frontend Rollback 323 | - Revert git commit and push 324 | - Or use hosting platform's rollback feature 325 | 326 | --- 327 | 328 | ## 📞 Support 329 | 330 | If you encounter issues: 331 | 332 | 1. Check the troubleshooting section above 333 | 2. Review Cloudflare Workers documentation 334 | 3. Check your hosting platform's docs 335 | 4. Verify all environment variables are set correctly -------------------------------------------------------------------------------- /apps/web/src/routes/install-pwa.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import { Download, Smartphone, Wifi, WifiOff, Zap, CheckCircle } from "lucide-react"; 5 | import { useEffect, useState } from "react"; 6 | import { createFileRoute } from "@tanstack/react-router"; 7 | 8 | export const Route = createFileRoute('/install-pwa')({ 9 | component: InstallPWAPage, 10 | }); 11 | 12 | function InstallPWAPage() { 13 | const [deferredPrompt, setDeferredPrompt] = useState(null); 14 | const [isInstalled, setIsInstalled] = useState(false); 15 | const [isStandalone, setIsStandalone] = useState(false); 16 | 17 | useEffect(() => { 18 | // Check if app is already installed 19 | setIsStandalone(window.matchMedia('(display-mode: standalone)').matches); 20 | 21 | // Check if app is installed via other methods 22 | if ('getInstalledRelatedApps' in navigator) { 23 | (navigator as any).getInstalledRelatedApps().then((relatedApps: any[]) => { 24 | setIsInstalled(relatedApps.length > 0); 25 | }); 26 | } 27 | 28 | // Listen for beforeinstallprompt event 29 | const handleBeforeInstallPrompt = (e: Event) => { 30 | e.preventDefault(); 31 | setDeferredPrompt(e); 32 | }; 33 | 34 | window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 35 | 36 | return () => { 37 | window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); 38 | }; 39 | }, []); 40 | 41 | const handleInstallClick = async () => { 42 | if (!deferredPrompt) { 43 | // Fallback for browsers that don't support beforeinstallprompt 44 | showInstallInstructions(); 45 | return; 46 | } 47 | 48 | deferredPrompt.prompt(); 49 | const { outcome } = await deferredPrompt.userChoice; 50 | 51 | if (outcome === 'accepted') { 52 | setIsInstalled(true); 53 | setDeferredPrompt(null); 54 | } 55 | }; 56 | 57 | const showInstallInstructions = () => { 58 | const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent); 59 | const isAndroid = /Android/.test(navigator.userAgent); 60 | 61 | if (isIOS) { 62 | alert('To install: Tap the Share button and select "Add to Home Screen"'); 63 | } else if (isAndroid) { 64 | alert('To install: Tap the menu button and select "Add to Home Screen" or "Install App"'); 65 | } else { 66 | alert('To install: Click the install icon in your browser\'s address bar or use the browser menu'); 67 | } 68 | }; 69 | 70 | const features = [ 71 | { 72 | icon: , 73 | title: "Online Sync", 74 | description: "Sync your todos across all devices when connected" 75 | }, 76 | { 77 | icon: , 78 | title: "Offline Support", 79 | description: "Work on your todos even without internet connection" 80 | }, 81 | { 82 | icon: , 83 | title: "Fast Performance", 84 | description: "Lightning-fast loading and smooth interactions" 85 | }, 86 | { 87 | icon: , 88 | title: "Native Feel", 89 | description: "Works like a native app on your device" 90 | } 91 | ]; 92 | 93 | if (isInstalled || isStandalone) { 94 | return ( 95 |
96 | 97 | 98 |
99 | 100 |
101 | App Already Installed! 102 | 103 | Ecomantem is already installed on your device. You can access it from your home screen or app drawer. 104 | 105 |
106 | 107 | 110 | 111 |
112 |
113 | ); 114 | } 115 | 116 | return ( 117 |
118 | {/* Hero Section */} 119 |
120 |
121 | 122 |
123 |

Install Ecomantem

124 |

125 | Get the full experience with offline support and native app features 126 |

127 |
128 | Offline-First 129 | Image Support 130 | Cross-Platform 131 |
132 |
133 | 134 | {/* Install Button */} 135 |
136 | 144 |

145 | Free • No ads • No tracking 146 |

147 |
148 | 149 | {/* Features Grid */} 150 |
151 | {features.map((feature, index) => ( 152 | 153 | 154 |
155 |
156 | {feature.icon} 157 |
158 | {feature.title} 159 |
160 |
161 | 162 | {feature.description} 163 | 164 |
165 | ))} 166 |
167 | 168 | {/* Manual Installation Instructions */} 169 | 170 | 171 | Manual Installation 172 | 173 | If the install button doesn't work, follow these steps: 174 | 175 | 176 | 177 |
178 |
179 |
📱
180 |

iOS Safari

181 |

182 | Tap the Share button → "Add to Home Screen" 183 |

184 |
185 |
186 |
🤖
187 |

Android Chrome

188 |

189 | Tap menu → "Add to Home Screen" or "Install App" 190 |

191 |
192 |
193 |
💻
194 |

Desktop

195 |

196 | Click install icon in address bar or browser menu 197 |

198 |
199 |
200 |
201 |
202 | 203 | {/* App Info */} 204 | 205 | 206 | About Ecomantem 207 | 208 | 209 |
210 |
211 |

What you get:

212 |
    213 |
  • • Offline todo management with image support
  • 214 |
  • • Automatic sync when you're back online
  • 215 |
  • • Native app experience on your device
  • 216 |
  • • No downloads or app store required
  • 217 |
  • • Works on all your devices
  • 218 |
219 |
220 |
221 |

Privacy & Security:

222 |
    223 |
  • • Your data stays on your device
  • 224 |
  • • No tracking or analytics
  • 225 |
  • • Open source and transparent
  • 226 |
  • • No account required
  • 227 |
228 |
229 |
230 |
231 |
232 |
233 | ); 234 | } -------------------------------------------------------------------------------- /apps/web/src/components/ui/dropdown-menu.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui" 5 | import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | function DropdownMenu({ 10 | ...props 11 | }: React.ComponentProps) { 12 | return 13 | } 14 | 15 | function DropdownMenuPortal({ 16 | ...props 17 | }: React.ComponentProps) { 18 | return ( 19 | 20 | ) 21 | } 22 | 23 | function DropdownMenuTrigger({ 24 | ...props 25 | }: React.ComponentProps) { 26 | return ( 27 | 31 | ) 32 | } 33 | 34 | function DropdownMenuContent({ 35 | className, 36 | sideOffset = 4, 37 | ...props 38 | }: React.ComponentProps) { 39 | return ( 40 | 41 | 50 | 51 | ) 52 | } 53 | 54 | function DropdownMenuGroup({ 55 | ...props 56 | }: React.ComponentProps) { 57 | return ( 58 | 59 | ) 60 | } 61 | 62 | function DropdownMenuItem({ 63 | className, 64 | inset, 65 | variant = "default", 66 | ...props 67 | }: React.ComponentProps & { 68 | inset?: boolean 69 | variant?: "default" | "destructive" 70 | }) { 71 | return ( 72 | 82 | ) 83 | } 84 | 85 | function DropdownMenuCheckboxItem({ 86 | className, 87 | children, 88 | checked, 89 | ...props 90 | }: React.ComponentProps) { 91 | return ( 92 | 101 | 102 | 103 | 104 | 105 | 106 | {children} 107 | 108 | ) 109 | } 110 | 111 | function DropdownMenuRadioGroup({ 112 | ...props 113 | }: React.ComponentProps) { 114 | return ( 115 | 119 | ) 120 | } 121 | 122 | function DropdownMenuRadioItem({ 123 | className, 124 | children, 125 | ...props 126 | }: React.ComponentProps) { 127 | return ( 128 | 136 | 137 | 138 | 139 | 140 | 141 | {children} 142 | 143 | ) 144 | } 145 | 146 | function DropdownMenuLabel({ 147 | className, 148 | inset, 149 | ...props 150 | }: React.ComponentProps & { 151 | inset?: boolean 152 | }) { 153 | return ( 154 | 163 | ) 164 | } 165 | 166 | function DropdownMenuSeparator({ 167 | className, 168 | ...props 169 | }: React.ComponentProps) { 170 | return ( 171 | 176 | ) 177 | } 178 | 179 | function DropdownMenuShortcut({ 180 | className, 181 | ...props 182 | }: React.ComponentProps<"span">) { 183 | return ( 184 | 192 | ) 193 | } 194 | 195 | function DropdownMenuSub({ 196 | ...props 197 | }: React.ComponentProps) { 198 | return 199 | } 200 | 201 | function DropdownMenuSubTrigger({ 202 | className, 203 | inset, 204 | children, 205 | ...props 206 | }: React.ComponentProps & { 207 | inset?: boolean 208 | }) { 209 | return ( 210 | 219 | {children} 220 | 221 | 222 | ) 223 | } 224 | 225 | function DropdownMenuSubContent({ 226 | className, 227 | ...props 228 | }: React.ComponentProps) { 229 | return ( 230 | 238 | ) 239 | } 240 | 241 | export { 242 | DropdownMenu, 243 | DropdownMenuPortal, 244 | DropdownMenuTrigger, 245 | DropdownMenuContent, 246 | DropdownMenuGroup, 247 | DropdownMenuLabel, 248 | DropdownMenuItem, 249 | DropdownMenuCheckboxItem, 250 | DropdownMenuRadioGroup, 251 | DropdownMenuRadioItem, 252 | DropdownMenuSeparator, 253 | DropdownMenuShortcut, 254 | DropdownMenuSub, 255 | DropdownMenuSubTrigger, 256 | DropdownMenuSubContent, 257 | } 258 | --------------------------------------------------------------------------------