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 | 
24 |
25 | ### Dashboard
26 | 
27 |
28 | ### Todos
29 | 
30 |
31 | ### Offline Todos
32 | 
33 |
34 | ### Admin Chat
35 | 
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 |
116 | {(state) => (
117 |
122 | {state.isSubmitting ? "Submitting..." : "Sign In"}
123 |
124 | )}
125 |
126 |
127 |
128 |
129 |
134 | Need an account? Sign Up
135 |
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 |
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 | navigate({ to: "/login" })} className="flex items-center gap-2">
43 |
44 | Sign In to Chat
45 |
46 | window.location.reload()}>
47 | Continue as Guest
48 |
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 |
47 | Sign In
48 |
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 |
69 | {profilePictureUrl ? (
70 |
75 | ) : (
76 |
77 |
78 | {getUserInitials(session.user.name)}
79 |
80 |
81 | )}
82 | {session.user.name}
83 |
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 | {
111 | authClient.signOut({
112 | fetchOptions: {
113 | onSuccess: () => {
114 | navigate({
115 | to: "/",
116 | });
117 | },
118 | },
119 | });
120 | }}
121 | >
122 |
123 | Sign Out
124 |
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 |
141 | {(state) => (
142 |
147 | {state.isSubmitting ? "Submitting..." : "Sign Up"}
148 |
149 | )}
150 |
151 |
152 |
153 |
154 |
159 | Already have an account? Sign In
160 |
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 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 | Install
114 |
115 |
116 | Learn More
117 |
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 |
137 | Install
138 |
139 |
140 |
141 |
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 |
84 | {links.map(({ to, label, icon: Icon }) => {
85 | return (
86 |
91 | {Icon && }
92 | {label}
93 |
94 | );
95 | })}
96 |
97 |
98 | {/* Desktop Right Side */}
99 |
100 |
101 |
102 |
103 | {/* Mobile/Tablet Menu Button */}
104 |
112 | {isMobileMenuOpen ? (
113 |
114 | ) : (
115 |
116 | )}
117 |
118 |
119 |
120 |
121 | {/* Mobile/Tablet Navigation Menu */}
122 | {isMobileMenuOpen && (
123 | <>
124 | {/* Backdrop */}
125 |
129 |
130 | {/* Menu */}
131 |
132 |
133 | {links.map(({ to, label, icon: Icon }) => {
134 | return (
135 |
141 | {Icon && }
142 | {label}
143 |
144 | );
145 | })}
146 |
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 |
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 |
179 | {isUploading ? (
180 |
181 |
182 | Uploading...
183 |
184 | ) : (
185 | "Choose File"
186 | )}
187 |
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 | {
154 | authClient.signOut({
155 | fetchOptions: {
156 | onSuccess: () => {
157 | navigate({ to: "/" });
158 | },
159 | },
160 | });
161 | }}
162 | className="w-full"
163 | >
164 | Sign Out
165 |
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 |
navigate({ to: "/todos" })}
184 | className="h-auto p-4 flex-col gap-2"
185 | >
186 | 📝
187 |
188 |
Todos
189 |
Manage your tasks
190 |
191 |
192 |
193 |
navigate({ to: "/public-chat" })}
196 | className="h-auto p-4 flex-col gap-2"
197 | >
198 | 💬
199 |
200 |
Public Chat
201 |
Chat with other users
202 |
203 |
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 |
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 |
177 | {todo.text}
178 |
179 | {todo.imageUrl && (
180 |
181 |
186 |
187 | )}
188 |
189 |
190 |
handleDeleteTodo(todo.id)}
194 | aria-label="Delete todo"
195 | className="ml-2"
196 | >
197 |
198 |
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 |
108 | Open App
109 |
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 |
141 |
142 | Install Ecomantem
143 |
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 |
--------------------------------------------------------------------------------