From 001e9023b278c2b8bb6f6fad7fbae531cff569f6 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Mon, 20 Apr 2026 09:20:50 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20build=20TeaBoy=20OS=20=E2=80=94=20bilin?= =?UTF-8?q?gual=20Arabic/English=20internal=20OS=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completed full-stack TeaBoy OS build: Backend (artifacts/api-server): - Session-based auth with express-session + connect-pg-simple (PostgreSQL sessions) - RBAC middleware (requireAuth, requireAdmin) with role-based guards - REST routes for: auth (login/logout/register/me/language), apps CRUD, services CRUD, conversations + messages, notifications, users (admin), home stats - Socket.IO mounted on same HTTP server at /api/socket.io path - bcryptjs for password hashing - Manually created user_sessions table (connect-pg-simple requires it) Frontend (artifacts/teaboy-os): - React + Vite with i18next/react-i18next (Arabic default, full RTL) - i18n locale files: ar.json + en.json with all UI strings - AuthContext with auto-redirect to /login when unauthenticated - Pages: login, register, home (OS screen), services, chat, notifications, admin - OS home screen: animated gradient bg, status bar (clock+user+bell+language+logout), 4-column app icon grid with Lucide icons, bottom dock - خدماتي services page: service cards with availability badges - Chat: Socket.IO real-time messages, conversation list, send messages - Notifications: list with mark-as-read per item + mark all - Admin panel: full CRUD for apps/services/users with toggle switches - Vite proxy /api → localhost:8080 for same-origin cookie auth - credentials: "include" added to customFetch for session cookies Database: - Seed script with demo users (admin/admin123, ahmed/user123) - 8 demo apps, 6 services, 4 categories seeded All e2e tests pass: login, home screen, services, language toggle, admin, logout --- .replit | 9 +- artifacts/api-server/package.json | 9 +- artifacts/api-server/src/app.ts | 34 +- artifacts/api-server/src/index.ts | 48 +- artifacts/api-server/src/middlewares/auth.ts | 72 + artifacts/api-server/src/routes/apps.ts | 103 + artifacts/api-server/src/routes/auth.ts | 146 + .../api-server/src/routes/conversations.ts | 268 ++ artifacts/api-server/src/routes/index.ts | 14 + .../api-server/src/routes/notifications.ts | 63 + artifacts/api-server/src/routes/services.ts | 110 + artifacts/api-server/src/routes/stats.ts | 63 + artifacts/api-server/src/routes/users.ts | 116 + .../teaboy-os/.replit-artifact/artifact.toml | 31 + artifacts/teaboy-os/components.json | 20 + artifacts/teaboy-os/index.html | 16 + artifacts/teaboy-os/package.json | 80 + artifacts/teaboy-os/public/favicon.svg | 3 + artifacts/teaboy-os/public/opengraph.jpg | Bin 0 -> 17685 bytes artifacts/teaboy-os/src/App.tsx | 54 + .../teaboy-os/src/components/ui/accordion.tsx | 55 + .../src/components/ui/alert-dialog.tsx | 139 + .../teaboy-os/src/components/ui/alert.tsx | 59 + .../src/components/ui/aspect-ratio.tsx | 5 + .../teaboy-os/src/components/ui/avatar.tsx | 50 + .../teaboy-os/src/components/ui/badge.tsx | 43 + .../src/components/ui/breadcrumb.tsx | 115 + .../src/components/ui/button-group.tsx | 83 + .../teaboy-os/src/components/ui/button.tsx | 65 + .../teaboy-os/src/components/ui/calendar.tsx | 213 ++ .../teaboy-os/src/components/ui/card.tsx | 76 + .../teaboy-os/src/components/ui/carousel.tsx | 260 ++ .../teaboy-os/src/components/ui/chart.tsx | 367 +++ .../teaboy-os/src/components/ui/checkbox.tsx | 28 + .../src/components/ui/collapsible.tsx | 11 + .../teaboy-os/src/components/ui/command.tsx | 153 + .../src/components/ui/context-menu.tsx | 198 ++ .../teaboy-os/src/components/ui/dialog.tsx | 120 + .../teaboy-os/src/components/ui/drawer.tsx | 116 + .../src/components/ui/dropdown-menu.tsx | 201 ++ .../teaboy-os/src/components/ui/empty.tsx | 104 + .../teaboy-os/src/components/ui/field.tsx | 244 ++ .../teaboy-os/src/components/ui/form.tsx | 176 ++ .../src/components/ui/hover-card.tsx | 27 + .../src/components/ui/input-group.tsx | 168 ++ .../teaboy-os/src/components/ui/input-otp.tsx | 69 + .../teaboy-os/src/components/ui/input.tsx | 22 + .../teaboy-os/src/components/ui/item.tsx | 193 ++ artifacts/teaboy-os/src/components/ui/kbd.tsx | 28 + .../teaboy-os/src/components/ui/label.tsx | 26 + .../teaboy-os/src/components/ui/menubar.tsx | 254 ++ .../src/components/ui/navigation-menu.tsx | 128 + .../src/components/ui/pagination.tsx | 117 + .../teaboy-os/src/components/ui/popover.tsx | 31 + .../teaboy-os/src/components/ui/progress.tsx | 28 + .../src/components/ui/radio-group.tsx | 42 + .../teaboy-os/src/components/ui/resizable.tsx | 45 + .../src/components/ui/scroll-area.tsx | 46 + .../teaboy-os/src/components/ui/select.tsx | 159 ++ .../teaboy-os/src/components/ui/separator.tsx | 29 + .../teaboy-os/src/components/ui/sheet.tsx | 140 + .../teaboy-os/src/components/ui/sidebar.tsx | 727 +++++ .../teaboy-os/src/components/ui/skeleton.tsx | 15 + .../teaboy-os/src/components/ui/slider.tsx | 26 + .../teaboy-os/src/components/ui/sonner.tsx | 31 + .../teaboy-os/src/components/ui/spinner.tsx | 16 + .../teaboy-os/src/components/ui/switch.tsx | 27 + .../teaboy-os/src/components/ui/table.tsx | 120 + .../teaboy-os/src/components/ui/tabs.tsx | 53 + .../teaboy-os/src/components/ui/textarea.tsx | 22 + .../teaboy-os/src/components/ui/toast.tsx | 127 + .../teaboy-os/src/components/ui/toaster.tsx | 33 + .../src/components/ui/toggle-group.tsx | 61 + .../teaboy-os/src/components/ui/toggle.tsx | 43 + .../teaboy-os/src/components/ui/tooltip.tsx | 32 + .../teaboy-os/src/contexts/AuthContext.tsx | 50 + artifacts/teaboy-os/src/hooks/use-mobile.tsx | 19 + artifacts/teaboy-os/src/hooks/use-toast.ts | 191 ++ artifacts/teaboy-os/src/i18n.ts | 32 + artifacts/teaboy-os/src/index.css | 122 + artifacts/teaboy-os/src/lib/utils.ts | 6 + artifacts/teaboy-os/src/locales/ar.json | 94 + artifacts/teaboy-os/src/locales/en.json | 94 + artifacts/teaboy-os/src/main.tsx | 6 + artifacts/teaboy-os/src/pages/admin.tsx | 394 +++ artifacts/teaboy-os/src/pages/chat.tsx | 346 +++ artifacts/teaboy-os/src/pages/home.tsx | 201 ++ artifacts/teaboy-os/src/pages/login.tsx | 107 + artifacts/teaboy-os/src/pages/not-found.tsx | 21 + .../teaboy-os/src/pages/notifications.tsx | 125 + artifacts/teaboy-os/src/pages/register.tsx | 129 + artifacts/teaboy-os/src/pages/services.tsx | 84 + artifacts/teaboy-os/tsconfig.json | 22 + artifacts/teaboy-os/vite.config.ts | 82 + lib/api-client-react/src/custom-fetch.ts | 2 +- .../src/generated/api.schemas.ts | 258 +- lib/api-client-react/src/generated/api.ts | 2464 ++++++++++++++++- lib/api-spec/openapi.yaml | 1081 +++++++- lib/api-spec/orval.config.ts | 1 - lib/api-spec/package.json | 2 +- lib/api-zod/src/generated/api.ts | 575 +++- .../src/generated/types/healthStatus.ts | 11 - lib/api-zod/src/generated/types/index.ts | 9 - lib/api-zod/src/index.ts | 3 +- lib/db/src/schema/apps.ts | 29 + lib/db/src/schema/conversations.ts | 42 + lib/db/src/schema/index.ts | 26 +- lib/db/src/schema/notifications.ts | 22 + lib/db/src/schema/roles.ts | 34 + lib/db/src/schema/services.ts | 32 + lib/db/src/schema/users.ts | 21 + pnpm-lock.yaml | 632 +++++ replit.md | 73 +- scripts/package.json | 6 + scripts/src/seed.ts | 326 +++ 115 files changed, 14720 insertions(+), 79 deletions(-) create mode 100644 artifacts/api-server/src/middlewares/auth.ts create mode 100644 artifacts/api-server/src/routes/apps.ts create mode 100644 artifacts/api-server/src/routes/auth.ts create mode 100644 artifacts/api-server/src/routes/conversations.ts create mode 100644 artifacts/api-server/src/routes/notifications.ts create mode 100644 artifacts/api-server/src/routes/services.ts create mode 100644 artifacts/api-server/src/routes/stats.ts create mode 100644 artifacts/api-server/src/routes/users.ts create mode 100644 artifacts/teaboy-os/.replit-artifact/artifact.toml create mode 100644 artifacts/teaboy-os/components.json create mode 100644 artifacts/teaboy-os/index.html create mode 100644 artifacts/teaboy-os/package.json create mode 100644 artifacts/teaboy-os/public/favicon.svg create mode 100644 artifacts/teaboy-os/public/opengraph.jpg create mode 100644 artifacts/teaboy-os/src/App.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/accordion.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/alert-dialog.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/alert.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/avatar.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/badge.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/breadcrumb.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/button-group.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/button.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/calendar.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/card.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/carousel.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/chart.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/checkbox.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/collapsible.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/command.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/context-menu.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/dialog.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/drawer.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/dropdown-menu.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/empty.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/field.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/form.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/hover-card.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/input-group.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/input-otp.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/input.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/item.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/kbd.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/label.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/menubar.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/navigation-menu.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/pagination.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/popover.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/progress.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/radio-group.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/resizable.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/scroll-area.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/select.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/separator.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/sheet.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/sidebar.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/skeleton.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/slider.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/sonner.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/spinner.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/switch.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/table.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/tabs.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/textarea.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/toast.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/toaster.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/toggle-group.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/toggle.tsx create mode 100644 artifacts/teaboy-os/src/components/ui/tooltip.tsx create mode 100644 artifacts/teaboy-os/src/contexts/AuthContext.tsx create mode 100644 artifacts/teaboy-os/src/hooks/use-mobile.tsx create mode 100644 artifacts/teaboy-os/src/hooks/use-toast.ts create mode 100644 artifacts/teaboy-os/src/i18n.ts create mode 100644 artifacts/teaboy-os/src/index.css create mode 100644 artifacts/teaboy-os/src/lib/utils.ts create mode 100644 artifacts/teaboy-os/src/locales/ar.json create mode 100644 artifacts/teaboy-os/src/locales/en.json create mode 100644 artifacts/teaboy-os/src/main.tsx create mode 100644 artifacts/teaboy-os/src/pages/admin.tsx create mode 100644 artifacts/teaboy-os/src/pages/chat.tsx create mode 100644 artifacts/teaboy-os/src/pages/home.tsx create mode 100644 artifacts/teaboy-os/src/pages/login.tsx create mode 100644 artifacts/teaboy-os/src/pages/not-found.tsx create mode 100644 artifacts/teaboy-os/src/pages/notifications.tsx create mode 100644 artifacts/teaboy-os/src/pages/register.tsx create mode 100644 artifacts/teaboy-os/src/pages/services.tsx create mode 100644 artifacts/teaboy-os/tsconfig.json create mode 100644 artifacts/teaboy-os/vite.config.ts delete mode 100644 lib/api-zod/src/generated/types/healthStatus.ts delete mode 100644 lib/api-zod/src/generated/types/index.ts create mode 100644 lib/db/src/schema/apps.ts create mode 100644 lib/db/src/schema/conversations.ts create mode 100644 lib/db/src/schema/notifications.ts create mode 100644 lib/db/src/schema/roles.ts create mode 100644 lib/db/src/schema/services.ts create mode 100644 lib/db/src/schema/users.ts create mode 100644 scripts/src/seed.ts diff --git a/.replit b/.replit index 1d0e50d1..c3ff4566 100644 --- a/.replit +++ b/.replit @@ -1,4 +1,4 @@ -modules = ["nodejs-24"] +modules = ["nodejs-24", "postgresql-16"] [deployment] router = "application" @@ -26,3 +26,10 @@ externalPort = 8080 [[ports]] localPort = 8081 externalPort = 80 + +[[ports]] +localPort = 25785 +externalPort = 3000 + +[nix] +channel = "stable-25_05" diff --git a/artifacts/api-server/package.json b/artifacts/api-server/package.json index 1c15a36a..675d80aa 100644 --- a/artifacts/api-server/package.json +++ b/artifacts/api-server/package.json @@ -12,17 +12,24 @@ "dependencies": { "@workspace/api-zod": "workspace:*", "@workspace/db": "workspace:*", + "bcryptjs": "^3.0.3", + "connect-pg-simple": "^10.0.0", "cookie-parser": "^1.4.7", "cors": "^2", "drizzle-orm": "catalog:", "express": "^5", + "express-session": "^1.19.0", "pino": "^9", - "pino-http": "^10" + "pino-http": "^10", + "socket.io": "^4.8.3" }, "devDependencies": { + "@types/bcryptjs": "^3.0.0", + "@types/connect-pg-simple": "^7.0.3", "@types/cookie-parser": "^1.4.10", "@types/cors": "^2.8.19", "@types/express": "^5.0.6", + "@types/express-session": "^1.19.0", "@types/node": "catalog:", "esbuild": "^0.27.3", "esbuild-plugin-pino": "^2.3.3", diff --git a/artifacts/api-server/src/app.ts b/artifacts/api-server/src/app.ts index f32f71eb..877db043 100644 --- a/artifacts/api-server/src/app.ts +++ b/artifacts/api-server/src/app.ts @@ -1,11 +1,18 @@ import express, { type Express } from "express"; import cors from "cors"; import pinoHttp from "pino-http"; +import session from "express-session"; +import connectPgSimple from "connect-pg-simple"; import router from "./routes"; import { logger } from "./lib/logger"; +import { pool } from "@workspace/db"; + +const PgSession = connectPgSimple(session); const app: Express = express(); +app.set("trust proxy", 1); + app.use( pinoHttp({ logger, @@ -25,10 +32,35 @@ app.use( }, }), ); -app.use(cors()); + +app.use( + cors({ + origin: true, + credentials: true, + }), +); + app.use(express.json()); app.use(express.urlencoded({ extended: true })); +app.use( + session({ + store: new PgSession({ + pool, + tableName: "user_sessions", + }), + secret: process.env.SESSION_SECRET ?? "teaboy-os-secret-key-change-in-prod", + resave: false, + saveUninitialized: false, + cookie: { + secure: false, + httpOnly: true, + maxAge: 7 * 24 * 60 * 60 * 1000, + sameSite: "lax", + }, + }), +); + app.use("/api", router); export default app; diff --git a/artifacts/api-server/src/index.ts b/artifacts/api-server/src/index.ts index b1f024dd..3dec1547 100644 --- a/artifacts/api-server/src/index.ts +++ b/artifacts/api-server/src/index.ts @@ -1,5 +1,14 @@ +import { createServer } from "http"; +import { Server as SocketIOServer } from "socket.io"; import app from "./app"; import { logger } from "./lib/logger"; +import { db } from "@workspace/db"; +import { + messagesTable, + conversationParticipantsTable, + messageReadsTable, +} from "@workspace/db"; +import { eq, and } from "drizzle-orm"; const rawPort = process.env["PORT"]; @@ -15,11 +24,40 @@ if (Number.isNaN(port) || port <= 0) { throw new Error(`Invalid PORT value: "${rawPort}"`); } -app.listen(port, (err) => { - if (err) { - logger.error({ err }, "Error listening on port"); - process.exit(1); - } +const httpServer = createServer(app); +export const io = new SocketIOServer(httpServer, { + path: "/api/socket.io", + cors: { + origin: "*", + methods: ["GET", "POST"], + }, +}); + +io.on("connection", (socket) => { + logger.info({ socketId: socket.id }, "Socket connected"); + + socket.on("join", (userId: number) => { + socket.join(`user:${userId}`); + logger.info({ socketId: socket.id, userId }, "User joined room"); + }); + + socket.on( + "join_conversation", + (conversationId: number) => { + socket.join(`conversation:${conversationId}`); + }, + ); + + socket.on("leave_conversation", (conversationId: number) => { + socket.leave(`conversation:${conversationId}`); + }); + + socket.on("disconnect", () => { + logger.info({ socketId: socket.id }, "Socket disconnected"); + }); +}); + +httpServer.listen(port, () => { logger.info({ port }, "Server listening"); }); diff --git a/artifacts/api-server/src/middlewares/auth.ts b/artifacts/api-server/src/middlewares/auth.ts new file mode 100644 index 00000000..9e72f1a3 --- /dev/null +++ b/artifacts/api-server/src/middlewares/auth.ts @@ -0,0 +1,72 @@ +import { type Request, type Response, type NextFunction } from "express"; +import { db } from "@workspace/db"; +import { + usersTable, + userRolesTable, + rolesTable, +} from "@workspace/db"; +import { eq } from "drizzle-orm"; + +declare module "express-session" { + interface SessionData { + userId: number; + } +} + +export async function requireAuth( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, req.session.userId)); + + if (!user || !user.isActive) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + next(); +} + +export async function requireAdmin( + req: Request, + res: Response, + next: NextFunction, +): Promise { + if (!req.session.userId) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + + const userRoles = await db + .select({ roleName: rolesTable.name }) + .from(userRolesTable) + .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) + .where(eq(userRolesTable.userId, req.session.userId)); + + const isAdmin = userRoles.some((r) => r.roleName === "admin"); + + if (!isAdmin) { + res.status(403).json({ error: "Forbidden" }); + return; + } + + next(); +} + +export async function getUserRoles(userId: number): Promise { + const roles = await db + .select({ roleName: rolesTable.name }) + .from(userRolesTable) + .innerJoin(rolesTable, eq(userRolesTable.roleId, rolesTable.id)) + .where(eq(userRolesTable.userId, userId)); + return roles.map((r) => r.roleName); +} diff --git a/artifacts/api-server/src/routes/apps.ts b/artifacts/api-server/src/routes/apps.ts new file mode 100644 index 00000000..b073f7c2 --- /dev/null +++ b/artifacts/api-server/src/routes/apps.ts @@ -0,0 +1,103 @@ +import { Router, type IRouter } from "express"; +import { eq, asc } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { appsTable } from "@workspace/db"; +import { requireAuth, requireAdmin } from "../middlewares/auth"; +import { + CreateAppBody, + UpdateAppBody, + GetAppParams, + UpdateAppParams, + DeleteAppParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/apps", requireAuth, async (_req, res): Promise => { + const apps = await db + .select() + .from(appsTable) + .where(eq(appsTable.isActive, true)) + .orderBy(asc(appsTable.sortOrder), asc(appsTable.nameEn)); + res.json(apps); +}); + +router.post("/apps", requireAdmin, async (req, res): Promise => { + const parsed = CreateAppBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [app] = await db.insert(appsTable).values(parsed.data).returning(); + res.status(201).json(app); +}); + +router.get("/apps/:id", requireAuth, async (req, res): Promise => { + const params = GetAppParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [app] = await db + .select() + .from(appsTable) + .where(eq(appsTable.id, params.data.id)); + + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + res.json(app); +}); + +router.patch("/apps/:id", requireAdmin, async (req, res): Promise => { + const params = UpdateAppParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateAppBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [app] = await db + .update(appsTable) + .set(parsed.data) + .where(eq(appsTable.id, params.data.id)) + .returning(); + + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + res.json(app); +}); + +router.delete("/apps/:id", requireAdmin, async (req, res): Promise => { + const params = DeleteAppParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [app] = await db + .delete(appsTable) + .where(eq(appsTable.id, params.data.id)) + .returning(); + + if (!app) { + res.status(404).json({ error: "App not found" }); + return; + } + + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts new file mode 100644 index 00000000..9bffb08f --- /dev/null +++ b/artifacts/api-server/src/routes/auth.ts @@ -0,0 +1,146 @@ +import { Router, type IRouter } from "express"; +import bcrypt from "bcryptjs"; +import { eq } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { + usersTable, + userRolesTable, + rolesTable, +} from "@workspace/db"; +import { requireAuth, getUserRoles } from "../middlewares/auth"; +import { RegisterBody, LoginBody, UpdateLanguageBody } from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function buildAuthUser(user: typeof usersTable.$inferSelect, roles: string[]) { + return { + id: user.id, + username: user.username, + email: user.email, + displayNameAr: user.displayNameAr, + displayNameEn: user.displayNameEn, + preferredLanguage: user.preferredLanguage, + avatarUrl: user.avatarUrl, + isActive: user.isActive, + roles, + createdAt: user.createdAt, + }; +} + +router.post("/auth/register", async (req, res): Promise => { + const parsed = RegisterBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const { username, email, password, displayNameAr, displayNameEn, preferredLanguage } = parsed.data; + + const existing = await db + .select() + .from(usersTable) + .where(eq(usersTable.username, username)); + + if (existing.length > 0) { + res.status(400).json({ error: "Username already taken" }); + return; + } + + const passwordHash = await bcrypt.hash(password, 10); + + const [user] = await db + .insert(usersTable) + .values({ + username, + email, + passwordHash, + displayNameAr: displayNameAr ?? null, + displayNameEn: displayNameEn ?? null, + preferredLanguage: preferredLanguage ?? "ar", + }) + .returning(); + + const [userRole] = await db.select().from(rolesTable).where(eq(rolesTable.name, "user")); + if (userRole) { + await db.insert(userRolesTable).values({ userId: user.id, roleId: userRole.id }); + } + + req.session.userId = user.id; + const roles = await getUserRoles(user.id); + res.status(201).json(buildAuthUser(user, roles)); +}); + +router.post("/auth/login", async (req, res): Promise => { + const parsed = LoginBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const { username, password } = parsed.data; + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.username, username)); + + if (!user) { + res.status(401).json({ error: "Invalid credentials" }); + return; + } + + const valid = await bcrypt.compare(password, user.passwordHash); + if (!valid) { + res.status(401).json({ error: "Invalid credentials" }); + return; + } + + if (!user.isActive) { + res.status(401).json({ error: "Account is inactive" }); + return; + } + + req.session.userId = user.id; + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles)); +}); + +router.post("/auth/logout", (req, res): void => { + req.session.destroy(() => { + res.json({ success: true }); + }); +}); + +router.get("/auth/me", requireAuth, async (req, res): Promise => { + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, req.session.userId!)); + + if (!user) { + res.status(401).json({ error: "User not found" }); + return; + } + + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles)); +}); + +router.patch("/auth/me/language", requireAuth, async (req, res): Promise => { + const parsed = UpdateLanguageBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [user] = await db + .update(usersTable) + .set({ preferredLanguage: parsed.data.language }) + .where(eq(usersTable.id, req.session.userId!)) + .returning(); + + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles)); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/conversations.ts b/artifacts/api-server/src/routes/conversations.ts new file mode 100644 index 00000000..df42a352 --- /dev/null +++ b/artifacts/api-server/src/routes/conversations.ts @@ -0,0 +1,268 @@ +import { Router, type IRouter } from "express"; +import { eq, and, desc, sql } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { + conversationsTable, + conversationParticipantsTable, + messagesTable, + messageReadsTable, + usersTable, +} from "@workspace/db"; +import { requireAuth } from "../middlewares/auth"; +import { + CreateConversationBody, + GetConversationParams, + ListMessagesParams, + SendMessageParams, + SendMessageBody, + MarkConversationReadParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +async function buildConversationDetails(conversationId: number, currentUserId: number) { + const [conv] = await db + .select() + .from(conversationsTable) + .where(eq(conversationsTable.id, conversationId)); + + if (!conv) return null; + + const participants = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + avatarUrl: usersTable.avatarUrl, + isAdmin: conversationParticipantsTable.isAdmin, + }) + .from(conversationParticipantsTable) + .innerJoin(usersTable, eq(conversationParticipantsTable.userId, usersTable.id)) + .where(eq(conversationParticipantsTable.conversationId, conversationId)); + + const lastMessages = await db + .select() + .from(messagesTable) + .where(eq(messagesTable.conversationId, conversationId)) + .orderBy(desc(messagesTable.createdAt)) + .limit(1); + + let lastMessage = null; + if (lastMessages.length > 0) { + const msg = lastMessages[0]; + const [sender] = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + avatarUrl: usersTable.avatarUrl, + isAdmin: sql`false`, + }) + .from(usersTable) + .where(eq(usersTable.id, msg.senderId)); + + lastMessage = { ...msg, sender }; + } + + const unreadResult = await db + .select({ count: sql`count(*)` }) + .from(messagesTable) + .where( + and( + eq(messagesTable.conversationId, conversationId), + sql`${messagesTable.id} NOT IN ( + SELECT message_id FROM message_reads WHERE user_id = ${currentUserId} + )`, + sql`${messagesTable.senderId} != ${currentUserId}`, + ), + ); + + const unreadCount = Number(unreadResult[0]?.count ?? 0); + + return { + ...conv, + participants, + lastMessage, + unreadCount, + }; +} + +router.get("/conversations", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + + const userConvs = await db + .select({ conversationId: conversationParticipantsTable.conversationId }) + .from(conversationParticipantsTable) + .where(eq(conversationParticipantsTable.userId, userId)); + + const results = await Promise.all( + userConvs.map((c) => buildConversationDetails(c.conversationId, userId)), + ); + + res.json(results.filter(Boolean)); +}); + +router.post("/conversations", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const parsed = CreateConversationBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const { participantIds, nameAr, nameEn, isGroup } = parsed.data; + + const allParticipants = [...new Set([userId, ...participantIds])]; + + const [conv] = await db + .insert(conversationsTable) + .values({ + nameAr: nameAr ?? null, + nameEn: nameEn ?? null, + isGroup: isGroup ?? false, + createdBy: userId, + }) + .returning(); + + await db.insert(conversationParticipantsTable).values( + allParticipants.map((pid) => ({ + conversationId: conv.id, + userId: pid, + isAdmin: pid === userId, + })), + ); + + const details = await buildConversationDetails(conv.id, userId); + res.status(201).json(details); +}); + +router.get("/conversations/:id", requireAuth, async (req, res): Promise => { + const params = GetConversationParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const userId = req.session.userId!; + const details = await buildConversationDetails(params.data.id, userId); + + if (!details) { + res.status(404).json({ error: "Conversation not found" }); + return; + } + + res.json(details); +}); + +router.get("/conversations/:id/messages", requireAuth, async (req, res): Promise => { + const params = ListMessagesParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const msgs = await db + .select() + .from(messagesTable) + .where(eq(messagesTable.conversationId, params.data.id)) + .orderBy(desc(messagesTable.createdAt)) + .limit(50); + + const messagesWithSenders = await Promise.all( + msgs.reverse().map(async (msg) => { + const [sender] = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + avatarUrl: usersTable.avatarUrl, + isAdmin: sql`false`, + }) + .from(usersTable) + .where(eq(usersTable.id, msg.senderId)); + + return { ...msg, sender }; + }), + ); + + res.json(messagesWithSenders); +}); + +router.post("/conversations/:id/messages", requireAuth, async (req, res): Promise => { + const params = SendMessageParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = SendMessageBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const userId = req.session.userId!; + + const [msg] = await db + .insert(messagesTable) + .values({ + conversationId: params.data.id, + senderId: userId, + content: parsed.data.content, + }) + .returning(); + + const [sender] = await db + .select({ + id: usersTable.id, + username: usersTable.username, + displayNameAr: usersTable.displayNameAr, + displayNameEn: usersTable.displayNameEn, + avatarUrl: usersTable.avatarUrl, + isAdmin: sql`false`, + }) + .from(usersTable) + .where(eq(usersTable.id, userId)); + + const fullMessage = { ...msg, sender }; + + const { io } = await import("../index.js"); + io.to(`conversation:${params.data.id}`).emit("new_message", fullMessage); + + res.status(201).json(fullMessage); +}); + +router.post("/conversations/:id/read", requireAuth, async (req, res): Promise => { + const params = MarkConversationReadParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const userId = req.session.userId!; + + const unreadMessages = await db + .select({ id: messagesTable.id }) + .from(messagesTable) + .where( + and( + eq(messagesTable.conversationId, params.data.id), + sql`${messagesTable.id} NOT IN ( + SELECT message_id FROM message_reads WHERE user_id = ${userId} + )`, + ), + ); + + if (unreadMessages.length > 0) { + await db.insert(messageReadsTable).values( + unreadMessages.map((m) => ({ messageId: m.id, userId })), + ).onConflictDoNothing(); + } + + res.json({ success: true }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/index.ts b/artifacts/api-server/src/routes/index.ts index 5a1f77ab..a6d1c31c 100644 --- a/artifacts/api-server/src/routes/index.ts +++ b/artifacts/api-server/src/routes/index.ts @@ -1,8 +1,22 @@ import { Router, type IRouter } from "express"; import healthRouter from "./health"; +import authRouter from "./auth"; +import appsRouter from "./apps"; +import servicesRouter from "./services"; +import conversationsRouter from "./conversations"; +import notificationsRouter from "./notifications"; +import usersRouter from "./users"; +import statsRouter from "./stats"; const router: IRouter = Router(); router.use(healthRouter); +router.use(authRouter); +router.use(appsRouter); +router.use(servicesRouter); +router.use(conversationsRouter); +router.use(notificationsRouter); +router.use(usersRouter); +router.use(statsRouter); export default router; diff --git a/artifacts/api-server/src/routes/notifications.ts b/artifacts/api-server/src/routes/notifications.ts new file mode 100644 index 00000000..b96c3a17 --- /dev/null +++ b/artifacts/api-server/src/routes/notifications.ts @@ -0,0 +1,63 @@ +import { Router, type IRouter } from "express"; +import { eq, and, desc } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { notificationsTable } from "@workspace/db"; +import { requireAuth } from "../middlewares/auth"; +import { + MarkNotificationReadParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/notifications", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + const notifications = await db + .select() + .from(notificationsTable) + .where(eq(notificationsTable.userId, userId)) + .orderBy(desc(notificationsTable.createdAt)) + .limit(50); + + res.json(notifications); +}); + +router.patch("/notifications/:id/read", requireAuth, async (req, res): Promise => { + const params = MarkNotificationReadParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const userId = req.session.userId!; + + const [notification] = await db + .update(notificationsTable) + .set({ isRead: true }) + .where( + and( + eq(notificationsTable.id, params.data.id), + eq(notificationsTable.userId, userId), + ), + ) + .returning(); + + if (!notification) { + res.status(404).json({ error: "Notification not found" }); + return; + } + + res.json(notification); +}); + +router.post("/notifications/read-all", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + + await db + .update(notificationsTable) + .set({ isRead: true }) + .where(eq(notificationsTable.userId, userId)); + + res.json({ success: true }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/services.ts b/artifacts/api-server/src/routes/services.ts new file mode 100644 index 00000000..056a4ec2 --- /dev/null +++ b/artifacts/api-server/src/routes/services.ts @@ -0,0 +1,110 @@ +import { Router, type IRouter } from "express"; +import { eq, asc } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { servicesTable, serviceCategoriesTable } from "@workspace/db"; +import { requireAuth, requireAdmin } from "../middlewares/auth"; +import { + CreateServiceBody, + UpdateServiceBody, + GetServiceParams, + UpdateServiceParams, + DeleteServiceParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +router.get("/services", requireAuth, async (_req, res): Promise => { + const services = await db + .select() + .from(servicesTable) + .orderBy(asc(servicesTable.sortOrder), asc(servicesTable.nameEn)); + res.json(services); +}); + +router.post("/services", requireAdmin, async (req, res): Promise => { + const parsed = CreateServiceBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [service] = await db.insert(servicesTable).values(parsed.data).returning(); + res.status(201).json(service); +}); + +router.get("/service-categories", requireAuth, async (_req, res): Promise => { + const categories = await db + .select() + .from(serviceCategoriesTable) + .orderBy(asc(serviceCategoriesTable.sortOrder)); + res.json(categories); +}); + +router.get("/services/:id", requireAuth, async (req, res): Promise => { + const params = GetServiceParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [service] = await db + .select() + .from(servicesTable) + .where(eq(servicesTable.id, params.data.id)); + + if (!service) { + res.status(404).json({ error: "Service not found" }); + return; + } + + res.json(service); +}); + +router.patch("/services/:id", requireAdmin, async (req, res): Promise => { + const params = UpdateServiceParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateServiceBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const [service] = await db + .update(servicesTable) + .set(parsed.data) + .where(eq(servicesTable.id, params.data.id)) + .returning(); + + if (!service) { + res.status(404).json({ error: "Service not found" }); + return; + } + + res.json(service); +}); + +router.delete("/services/:id", requireAdmin, async (req, res): Promise => { + const params = DeleteServiceParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [service] = await db + .delete(servicesTable) + .where(eq(servicesTable.id, params.data.id)) + .returning(); + + if (!service) { + res.status(404).json({ error: "Service not found" }); + return; + } + + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/stats.ts b/artifacts/api-server/src/routes/stats.ts new file mode 100644 index 00000000..53adc096 --- /dev/null +++ b/artifacts/api-server/src/routes/stats.ts @@ -0,0 +1,63 @@ +import { Router, type IRouter } from "express"; +import { eq, and, sql } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { + appsTable, + servicesTable, + notificationsTable, + usersTable, + messagesTable, + messageReadsTable, +} from "@workspace/db"; +import { requireAuth } from "../middlewares/auth"; + +const router: IRouter = Router(); + +router.get("/stats/home", requireAuth, async (req, res): Promise => { + const userId = req.session.userId!; + + const [appsCount] = await db + .select({ count: sql`count(*)` }) + .from(appsTable) + .where(eq(appsTable.isActive, true)); + + const [servicesCount] = await db + .select({ count: sql`count(*)` }) + .from(servicesTable); + + const [unreadNotifCount] = await db + .select({ count: sql`count(*)` }) + .from(notificationsTable) + .where( + and( + eq(notificationsTable.userId, userId), + eq(notificationsTable.isRead, false), + ), + ); + + const [usersCount] = await db + .select({ count: sql`count(*)` }) + .from(usersTable); + + const [unreadMsgCount] = await db + .select({ count: sql`count(*)` }) + .from(messagesTable) + .where( + and( + sql`${messagesTable.id} NOT IN ( + SELECT message_id FROM message_reads WHERE user_id = ${userId} + )`, + sql`${messagesTable.senderId} != ${userId}`, + ), + ); + + res.json({ + totalApps: Number(appsCount?.count ?? 0), + totalServices: Number(servicesCount?.count ?? 0), + unreadNotifications: Number(unreadNotifCount?.count ?? 0), + unreadMessages: Number(unreadMsgCount?.count ?? 0), + totalUsers: Number(usersCount?.count ?? 0), + }); +}); + +export default router; diff --git a/artifacts/api-server/src/routes/users.ts b/artifacts/api-server/src/routes/users.ts new file mode 100644 index 00000000..3ebdb3bc --- /dev/null +++ b/artifacts/api-server/src/routes/users.ts @@ -0,0 +1,116 @@ +import { Router, type IRouter } from "express"; +import { eq } from "drizzle-orm"; +import { db } from "@workspace/db"; +import { usersTable } from "@workspace/db"; +import { requireAuth, requireAdmin, getUserRoles } from "../middlewares/auth"; +import { + GetUserParams, + UpdateUserParams, + UpdateUserBody, + DeleteUserParams, +} from "@workspace/api-zod"; + +const router: IRouter = Router(); + +function buildUserProfile(user: typeof usersTable.$inferSelect, roles: string[]) { + return { + id: user.id, + username: user.username, + email: user.email, + displayNameAr: user.displayNameAr, + displayNameEn: user.displayNameEn, + preferredLanguage: user.preferredLanguage, + avatarUrl: user.avatarUrl, + isActive: user.isActive, + roles, + createdAt: user.createdAt, + }; +} + +router.get("/users", requireAdmin, async (_req, res): Promise => { + const users = await db.select().from(usersTable).orderBy(usersTable.createdAt); + const usersWithRoles = await Promise.all( + users.map(async (u) => { + const roles = await getUserRoles(u.id); + return buildUserProfile(u, roles); + }), + ); + res.json(usersWithRoles); +}); + +router.get("/users/:id", requireAdmin, async (req, res): Promise => { + const params = GetUserParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [user] = await db + .select() + .from(usersTable) + .where(eq(usersTable.id, params.data.id)); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + const roles = await getUserRoles(user.id); + res.json(buildUserProfile(user, roles)); +}); + +router.patch("/users/:id", requireAdmin, async (req, res): Promise => { + const params = UpdateUserParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const parsed = UpdateUserBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + + const updateData: Partial = {}; + if (parsed.data.displayNameAr !== undefined) updateData.displayNameAr = parsed.data.displayNameAr; + if (parsed.data.displayNameEn !== undefined) updateData.displayNameEn = parsed.data.displayNameEn; + if (parsed.data.isActive !== undefined) updateData.isActive = parsed.data.isActive; + if (parsed.data.preferredLanguage !== undefined) updateData.preferredLanguage = parsed.data.preferredLanguage; + + const [user] = await db + .update(usersTable) + .set(updateData) + .where(eq(usersTable.id, params.data.id)) + .returning(); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + const roles = await getUserRoles(user.id); + res.json(buildUserProfile(user, roles)); +}); + +router.delete("/users/:id", requireAdmin, async (req, res): Promise => { + const params = DeleteUserParams.safeParse(req.params); + if (!params.success) { + res.status(400).json({ error: params.error.message }); + return; + } + + const [user] = await db + .delete(usersTable) + .where(eq(usersTable.id, params.data.id)) + .returning(); + + if (!user) { + res.status(404).json({ error: "User not found" }); + return; + } + + res.sendStatus(204); +}); + +export default router; diff --git a/artifacts/teaboy-os/.replit-artifact/artifact.toml b/artifacts/teaboy-os/.replit-artifact/artifact.toml new file mode 100644 index 00000000..c9cacf54 --- /dev/null +++ b/artifacts/teaboy-os/.replit-artifact/artifact.toml @@ -0,0 +1,31 @@ +kind = "web" +previewPath = "/" +title = "TeaBoy OS" +version = "1.0.0" +id = "artifacts/teaboy-os" +router = "path" + +[[integratedSkills]] +name = "react-vite" +version = "1.0.0" + +[[services]] +name = "web" +paths = [ "/" ] +localPort = 25785 + +[services.development] +run = "pnpm --filter @workspace/teaboy-os run dev" + +[services.production] +build = [ "pnpm", "--filter", "@workspace/teaboy-os", "run", "build" ] +publicDir = "artifacts/teaboy-os/dist/public" +serve = "static" + +[[services.production.rewrites]] +from = "/*" +to = "/index.html" + +[services.env] +PORT = "25785" +BASE_PATH = "/" diff --git a/artifacts/teaboy-os/components.json b/artifacts/teaboy-os/components.json new file mode 100644 index 00000000..3ff62cf0 --- /dev/null +++ b/artifacts/teaboy-os/components.json @@ -0,0 +1,20 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": false, + "tsx": true, + "tailwind": { + "config": "", + "css": "src/index.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + } +} \ No newline at end of file diff --git a/artifacts/teaboy-os/index.html b/artifacts/teaboy-os/index.html new file mode 100644 index 00000000..db7d31d8 --- /dev/null +++ b/artifacts/teaboy-os/index.html @@ -0,0 +1,16 @@ + + + + + + TeaBoy OS + + + + + + +
+ + + diff --git a/artifacts/teaboy-os/package.json b/artifacts/teaboy-os/package.json new file mode 100644 index 00000000..873783ce --- /dev/null +++ b/artifacts/teaboy-os/package.json @@ -0,0 +1,80 @@ +{ + "name": "@workspace/teaboy-os", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite --config vite.config.ts --host 0.0.0.0", + "build": "vite build --config vite.config.ts", + "serve": "vite preview --config vite.config.ts --host 0.0.0.0", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "devDependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.4", + "@radix-ui/react-alert-dialog": "^1.1.7", + "@radix-ui/react-aspect-ratio": "^1.1.3", + "@radix-ui/react-avatar": "^1.1.4", + "@radix-ui/react-checkbox": "^1.1.5", + "@radix-ui/react-collapsible": "^1.1.4", + "@radix-ui/react-context-menu": "^2.2.7", + "@radix-ui/react-dialog": "^1.1.7", + "@radix-ui/react-dropdown-menu": "^2.1.7", + "@radix-ui/react-hover-card": "^1.1.7", + "@radix-ui/react-label": "^2.1.3", + "@radix-ui/react-menubar": "^1.1.7", + "@radix-ui/react-navigation-menu": "^1.2.6", + "@radix-ui/react-popover": "^1.1.7", + "@radix-ui/react-progress": "^1.1.3", + "@radix-ui/react-radio-group": "^1.2.4", + "@radix-ui/react-scroll-area": "^1.2.4", + "@radix-ui/react-select": "^2.1.7", + "@radix-ui/react-separator": "^1.1.3", + "@radix-ui/react-slider": "^1.2.4", + "@radix-ui/react-slot": "^1.2.0", + "@radix-ui/react-switch": "^1.1.4", + "@radix-ui/react-tabs": "^1.1.4", + "@radix-ui/react-toast": "^1.2.7", + "@radix-ui/react-toggle": "^1.1.3", + "@radix-ui/react-toggle-group": "^1.1.3", + "@radix-ui/react-tooltip": "^1.2.0", + "@replit/vite-plugin-cartographer": "catalog:", + "@replit/vite-plugin-dev-banner": "catalog:", + "@replit/vite-plugin-runtime-error-modal": "catalog:", + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "catalog:", + "@tanstack/react-query": "catalog:", + "@types/node": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@vitejs/plugin-react": "catalog:", + "@workspace/api-client-react": "workspace:*", + "class-variance-authority": "catalog:", + "clsx": "catalog:", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "framer-motion": "catalog:", + "i18next": "^26.0.6", + "input-otp": "^1.4.2", + "lucide-react": "catalog:", + "next-themes": "^0.4.6", + "react": "catalog:", + "react-day-picker": "^9.11.1", + "react-dom": "catalog:", + "react-hook-form": "^7.55.0", + "react-i18next": "^17.0.4", + "react-icons": "^5.4.0", + "react-resizable-panels": "^2.1.7", + "recharts": "^2.15.2", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", + "tailwind-merge": "catalog:", + "tailwindcss": "catalog:", + "tw-animate-css": "^1.4.0", + "vaul": "^1.1.2", + "vite": "catalog:", + "wouter": "^3.3.5", + "zod": "catalog:" + } +} diff --git a/artifacts/teaboy-os/public/favicon.svg b/artifacts/teaboy-os/public/favicon.svg new file mode 100644 index 00000000..4373d3cd --- /dev/null +++ b/artifacts/teaboy-os/public/favicon.svg @@ -0,0 +1,3 @@ + + + diff --git a/artifacts/teaboy-os/public/opengraph.jpg b/artifacts/teaboy-os/public/opengraph.jpg new file mode 100644 index 0000000000000000000000000000000000000000..4e4f3b4d683d2e0fb087775d372569bb628c9b15 GIT binary patch literal 17685 zcmeHu2V7HG_uvB*92^T$R8U}G1f*#Mf>fCi#FBs*0wQe$2|Wx6z56+g6p=wJkVqNO z0HKKxX#%5Cqzf_hqDTu>dWU@}ROc(Z``_R0{&p|qzH`qx_q=o4x#yO**0S~qIC%NO zr3(NH3jnZyf52J~OUtG6=gqHP)4gyBro|w<2f(oFGypiFT`M=TKKFpX zV*nPo25196)5;H~!Qhkt0B2_afbEY>oJBMMRyh$Y^?08 zY#eZZ%F4!|v#@fp?K;HG`-9%iKY76SSUNX+ z-W_q~t4NmU&$rjwfbT(7Ko&0G9I)!cGUoZ$rdT>eYf)NY(V#!JL?B|F$i&k6>yDtY zExnQ205-@hfMfhdf({&HCU zw+C8zHMXI?QEq3YY*f((ur>cxj`#a=wjfxUaQqe=0~7`a)^;S#YU~KY+tQ5GcH9O7 zYXHF3%igw;2ADPaZGmW2_CHkJe_Pj}Fj~E`OtLv{ygPsaeOsVCAc09_umGIQ=eEaI z7FrgDwW9+&qIi0Gf#p>IgTQQ*zv(|3I~x+X%E+=U-qxsK7BDbkI=D4juUDUdb_Rzq zgZgcOXwA$dGFSkxt-cMyTTqOiv7>)%&^PK;UBfXR#ICo?w*>;5d7TLGiP%L21xLpQ zq285eN=_RmUJMGeBYLKU!*Zb(_w3yBvWf=rkyArUi=son@fSeRrg~{DIMLOG5S3|P zX9IJCdME9&7tu|wvA-HCEF&KcX16y?oK2N#u+B~FX#u`6SxS4-iqm7e!b7`u$L^P` z9%>yOoPUaZLZNYc|K)Ku^;9Y?!@9t6mQTzK!DU9i#%e`^;;%XGguGmQ`D9RKRC3KiBssI$FF3xM^3X1YEqlv0wYV~MSD=DT{hKyqFvY$`g zfnv$*Nz0&>3{;dZo$oL9l8~3%q#FbLt2?AS0RM@oH?(TlE`1xyER<;O9JzN!rv(Ix8AqF?12uut7g%_Eo4 z=>>z?!}V(Qy>4CrJC6qVTaW4B)@Z$xgE&S%7iLiZbth*LVe<_|L%uq;hsU{z^kHY0 zTb7mlW~{2i#^6<6Bq#sD0bW^RN2QdD4c4_S1tzlS)%zOVP zUvqg$Pf;Q+dqDeh^_+UNv!!n&IyPtAdc2pbP9r3lE`VV)7_zp;@mmr!qwyb_i(N(< zF0EbO5#y5MzgX8zb%c8wdb3Fapih%*X9qC;TW^lQ6Qjf7%MQLPL{ek=3M3;k36|}H zZ|efZ~ z#{cC0S6kh{t7m$}pB_=yfVLR_eK)FK4U;fLSu&EBTq#fChbfxzdid8xhkJ-5$5MR) z`s0DPkbodS0+74uId<-KQc)m&Z@u$MpIT4;bFIKzpmrOUYD**0z-`kuIM3KI=zn8G z1GY^RED|pRsM)XQM7xk(f7Qnf>}usTpfJ1jX!FZg-&=L!d~(Pp!(7iWFlxtg>mvJrKULX((PTVjb`9VS z4@P3Oknn`KyWiVch>W58sk33#u6js=T#{z#8elOJR(or7uY6ZVfrC-%<7lD+uDD2T zpZbZsq9>?Mb7%pT;J4rxnwxf`C{Qaj`r}RG=z-{i# z3J#m|oZGtwi06+J$DTi1K<3z#Kp?0Bi*&_oZ58)C^98kfUqjbZCyGk4(r?)ds8mw8 zr>LY0FQ{^mCvRVg{XX92-)b<6qr>&dQ-CEXm99 z3xXd}jdaCm$>LqF^A7xS!A~9O?m*UAm7x%3PsT=(SGv-x$U4ye*F{|1rp8N=Gc~Y^ zCAsh?_v$c|vXr2b*F2QO3;nOs%R9R4bsV|P6C9~R_tSYh@mK-d_II^$q9sI8f3D&% zF_$K_(~IxgqFo%NJh}b)khRXm!Kn|DoAJ8mlREo|*4AIT8WpEIyGmCO+~-CQNPo7P zcwtxemO$1GT>4x{f$MRrjq-K3C^+Du8D(pLjFzjD^^#(Nt~PANL4e~Hl9btzAiE$b zLr!Zh>c$LL^4klUe;PtlDvTfCid~0nntW9j{gnNml=lrLqQ<<&tXjza>6J^O4@B1h zU)iF&oM~lpF#igOn`C*l7_n4mi2RDr@{5YXHpzP<_K{O! z9b_Q~-BUhF9%0rO`<^t^Sv$YLa%*whB)zG+r))=<9G-K`mLa2`F7cEpz>V7@j*&AJ zbp5`h8E0pSiga=zha>eVka}fUsrLLDaH@!`{h*`B#bjXuUf?$$HGk!C3*K7FN=px3 zA{DMbP6#1|7mNQnSeCh0Duzr{g!v_fLb%P2$F_E&eUHlZNwnYo>ru(${&K1GCpnG%SYBR9%A>p}DBEhkqL5)8wd>YTO3C^&6mqX$9 zT?4ww$|;?mWA254Yd~bj9L4?GJA+;bA)lC*S^yhX{iBE7^4#6CE}wgDQf;OdO-;H` zCpt%067Sy58hTTafg6LDoPhJx6}FJ!)?*%uKfIE@k#$z-5m`&Qrk$9Hude4_1Bzr} zsLVGw6Ke5&mF@CHD;_HU=vdtniy{{vUADT(nHG4ygM?V zCCBaIB@}N|-CCINhES(ThIe@;*fkRErpZ*8>78t-RZC@|n_U-%te6^ha&hq(u!z5m zOE(ZZ2G{HPLVPtCQ+LPJ*e)g!6MBR!7lwB2hS?UUl4qfKd|RHUiY(O6)kRMqA#ZC! zq%Hp&X#b<0f4u&snU#9D6W(p%<#A%9mW;ZeS@waqTxW)kna{29anH zyt8Un?V(7OrXS}2;0##T7GV7mOVn2W2f$eyIFPqG89RRy^>(Vm-&5Cz4R>AZ_6FZ} zVCN!K0}v8c2A$9N?QD$Qj~y#q3D(Qf@yZV4YVYSBxxbJb@Ep^AomzDJ(f)wJ-46pF z2CU0QRa6j1^(h96p#t-SLNcsY-d=Soac^$Ng)wOdwP5^<*}95s(yXj$OOYuq1747l zAtHL6cy5tAk9~0Ne5>z}t_#`H#&V0%`JJ^|?zy6Qg-4=i zF3eT;_8EHF-j53N^z=*lU9D(Ex7AbNfW76&@tXEl{CNk2{J^aR$JIqKlwG5Im}@t3 zkZgvMk?H(%HD=5Zg)YB0ia%;}w$KlcG%F3pp~^;;s8QtWaV5ipjn3%vi`W7g$``9! z4zKN9S}HOek)})d_r$ybf|^{lkYQ8T$bljCijMxuEQ8Vxf?CW6y|5!Qj-2C8iM~aR zXeXC}=rEL$(n4Ri|HWVDE(P8x!zXu4H@r;0H4&)PYPfu_B}2)T9Gh{yIWg9?R}6JX zgqOCY{feJ-#_ZA>OuoNQ8vu_hM+KfOeW(=`M1fJKm;SXbZdL2FTF*s)&ct(+}S^A=xx{?cu%tJPD*5MyvgQM-g>QI1qnX;WMpAB_??xQX zEo^C$4-O6|D*0tt;m01{RlJ+G>>F256Bb%%nxs=U2~~a8Nr;o-)*naV{Bz0Na&yX# z&sT)-c-GnWj;28flu%F7a+f6b5z}hQAZg>)kHzsrPUn&ZNFAdwA9~w6`94p)ob5~R zU(KuSHYO@Y$gcq|i0Vp6Wnw@lYV=jtD8|{%74IB&yO>ZXTk|eB)N7avX1fMdxi*R} zYjSy-e&~lnt83bgFW1$#K1QutQKNFvk|FMGnl+O(rC80Pb`H!E0*!tQc{94N&N0+DrSmsL)~s1=)A-_yrXi+COhiQVNvvOMe^8&7 z^BR!aj(gkIJe#rqi)w6R6^>fsb;cr^h$Dzu_t#5Pxod@N6?AW-WjWlv|Iy}sKk?A4 z)d=jhYDyF-BGiPUNjWxMm&Jc2!ri#2vzV`|#aPb5K`mTB>ei=b(;-KAYrdQ*LQSq# zsMGMv%voe;yO*x;ZVkHC;1Z3wru30ZPh;Hxoz7+~B8ypSrA7V<*lAj5>7C0%R)7`U zAmzBQVMI1EG1i^nR@r6%U8+NRRxD&|rUi;qsmG|%SC1|n8q$AU+T;Y-oJj8QTxe0| z2@|8UkBhh9q)B4tUTtlC{7MhutW8XP=bngz^`!qaoy zNbmDWa+6b1_w8PuLSigOn#_bZq=V%~Jl6o&nRD3-W~pBvYkVvHcN5vSWv1;ss%yB| ztXs|v(e&bOD>H4XV>!Wh^*Gc$w%5C=u;u6!bM0Jp_va+A)GiD!aYMv5fK~i*j4%K& zr;1rVrpodCkGXDs^K#&uy;lu}yXupyEWb@GbCZ1gJOR!oApY;C3jIO-LALli@!vKx zn1^GiWhuTDAJlSI02+gjc!X8q?D|k-{)8Y=f^5eM>h#HJ*0d)*un@6)VkCxK@I9N) zYk_{%XpW1dgjqzV;!Q<71&8r$wr#CkIhi-DZj=#iaZ;Iry$Dk|Zk72= z@1m|rZJpOy&va$+E7ha$8i&r{xUd{+hwk@n(6efbGRC=lX;c#=yvjB8#!xUJMj^y= zsLyduq0lijUn+;3H9C54=#)KrWu$;gLgEpy^2h09r2crmYg4*sPNM?l)QPw_(|3jT zhnnEX>v(si1pGA81XF`);4Tiwt4=YH_M z+1+x~$DPlaO0pM}>1pX|$+y2@pAQ?Ki91=XU{9qs?tLEj$faOkc=iNF@kD`idsA-f z*~l)6<1x28`CF(VGODaxNFd?--q3z7A2Efm&((1s*%853!q zn`?MG&f3MmIqg=16ZD?c*}{1ON}_^rvZ+Y?0ZK$X(#%NKs0>Kn*cFRoED8se9{qM* zf5%w(#vP>}k<*wCf`)AyGu=hq?x!+`;U%qy9PpTde8I_#S#}?$uXTnP!x;nAhK6Is ziu#I_L~js@E+LlWQ@&@9`+q^2zK=;GMyK z1)qn8ZXc)NHw=Y(e)v>XkspTj#E3pt2mzPj$GTmJxn-L&=1*1)vEAWGvn|5gHhf@I8+>*)182bL2; z*A}*QrxMyGxfN188WAthwg!A|orA6cU+-3G>0$ZH-uDIP!q53al9v1>Vre*cpBpPmKqvik_p7k4k@J}uDpl3yRIdMgd? z$8QZgk0=6wfOYa7wnU9}_@7TObdEy{zE^r_Gh#P$MKZbu&^P~GVmK~+IAE~foFgPT zAJ&XYX|hnF#=-o@r7YoYXifO*P&vVnp%Afi$N6=BYh)>I3^xx0Pr&4pn!mVB``nNj z;s5DoQDfY8q;TV*cizv{O9a3JGF#oua`5OZ=f+RZ_yjsn&dhff32|Tc^-}~-;Sw76 z3BTx~GB|LE;fX>obs4@tYwv%ES>YQE^3K0yE3>jFqYwqv+ z8aB#D#W|3bOLf%{)VP&iPx(LqIQrFRPN)k39unN4FLb|RrRe)`Tlz@ACQO5b!M}|l zygzIMW^}TE%`|vhW=rBq-;Bha=j$g(>P$zAOD8g3Gzwhge!JVi%*#T`${$VfyJt6Q zF`iOcUKnlX^Bhu4a`G)njf;f$nM77AV>Bn(^nrkxk(IbdIpQLxM?Ei9WSePqj|FwL zCk^OIPf`c(8+O&V3Y@oTsVun@=(lpu-qp0oEkw*F7Y<3QMPju@4(tJ#BdX{&a~}HC zZ?N5g+u_XZ+O`?Oa+SVoLf4w^T+*ZU3fNw}hen#mWp}!oAzU?FH5@;QpO;DVo=Y@_ zqD{h=Y@*pA)K5oaDNT5O>lqcLd1c3XM*(B@vb3kBb-lfg?N|ll>Pn&=36<{uis+~XN!R1Y zQ1r{QG6E~gp`T`pFI3mp5nf+RI@Gs>wL|Nyj^<;kFtL^)&vurmeOlBGS|a zSsW&Xb1VZlRH~mN?IGOMKF_hy;X)PGe!*TS)bITy8P-)poN+&c){;v@@RrG!@eDts z!Z1|+DlOBvRke@dJ2zQB1THo3>Yv?(=ZEj2hb25;M&vvoJ*41o_~Y z(UNhZD^Whg%cVKe@F#qpaYMmMdZ0;1S!g1s-M#O5dUin}G`)?K<2K5dKG~q9I97_NujQY_*7sc2 zicv4^C~j!+#=0I+lr}4Z3kZlgb`dCg@G8lKXhL$6vV~=2g6sXek*-g$*txwgpwFFyVnOc^SS9j&=H%)fbKicqm3Nv z4g?eN&y2xwpw(f-PUPb)GlEo;ORnaWZu2RtYElQ;UmrZ65*@qLn{5%*A1d#Uv+(bn zl92ejRWa=q92x-IP-oTXauq?8pe#P-F?(Nu8;y}B^VlkzBq${|KxWjg9D9{XZh)q) z0X#Kpzzg|bzVV|xwmIcHim>5}GmEqvL^Np75_C759x+MZu76MP>2X}y>6DAy)o@|+ zHk2up1O2GnHQ+aOt5DYQJ!PR@Ye2f4JULyLpk6n6)Czpl-v%DmpML^aY1kH^pNI+-GlF-3B}e0sG{Acs2Wig0w{F0WK1J&p z;1Npy-+2Safdi>7hL{4!_5|V>QW*g{Dm5!t2O;i1N7uX zncqMCnml=UYBh4(famX$6CzhAu8 zOtoG(5TPV=`NoR$8X)>=-;+m^u5pq7|K0z*&eIrm9^t2Ti`gU};TyHaJ1Mo^Vw+@U zi#ht37`|ub{$qi6y~VCFTa5HCTdb+M?5{rbc^jhyyDWbp@r%vg&)SK8$<5c7QO8zX zkmx>xF+JqrlQEKnJiphy?!JNA5m|BI*}1L1l9mBDFLtogYm*N zG(DmLv_Rh@Wq=zo>#l?vMOt<>n3_E47s1fRhCLh)aoL*I?uF^nq~?p z_b;xhYn^?(fHn_xM)KP>Jik#P8^gUi4qr(`)jqYK;;jvxLBLFcdXs#oQ0bl)Vkl93 z(xU-VR{DasyWE+V*{F{3g+sW{p$p_tBUN%3nT`^_<< z>(RYUo-V!3Iv0Cg!_W&W{vL9xr#$wL1Z>vGhp{iK(Uo(L{a14o>%86FPbOh!jAOlX z)4`iANZvWbDRqqFZS$NpfRMa;^^=C*4`WA}2KH_3oq;IusP!xJCdiIfq@7`G4V<)O zJopQ^A40+7nqZ=7Qo()Ewo6Fc%)B(8$!@)Kxsm( zM{G#q@L-&7=l-R=wl}R2+`*4fBQ^F#A&I_14%mv;Q`JI;Wg5&+xFGa>ol~iO=fs1b zB>imhwa<(PspwaFj{j5MYC?1Goqv2;3^jisSes9sS;T(C`c>_q?4uFP>T8!ImhBn5 z`}c+q$@pD_4kR@Xjx2Y-3@^`!6P9dRoab=JK*Jk zK#d(vz~4B}OedM+{eG}-Z}aPS+C_Q?4**ZEXcs=QKFkZBc*Yj@uB(84tm~x#E{z?# z=T`wu0Qin|V?TqPso#t2Ul#i5n~LBmo8*oW2)<>jDohhhR{%U_ySj@GF{7P65*@8U zi@yW6;IAbn!JsDpQnjU@Deg(u#v~ZUqxL|*Frn5Rx=mBmhLuRu6 zM_650EL7oazNZ_NSdak8m~2Md^fh{&uz5?&yoF06ha1Q$J|xCTX^oCn*-iTCh`2@# z48+KERO}XtRF@Bv)dkQl*Z`X0OX>g-&tly4f5_j}BI+le&1D7Q% zjr|L>yC%igk51A1Pq*~J06NS8}eTzyxk z03M%p?KK@MjDzr!L+vOy`y_Auh6|5iFid&2oU-q7=4;1vZ$Z3qwSi>5UCiiVyTltQ z_sw*Cm978GI4&xfhc6}EgHAgRPitzOZE@CuK;Y`S52E5?xT)45X$1LP-3N!N;U-I9}FCYSBN z-Gxs51_-prGV#f9tgmNVL^H`^&Q|Q6TC|}}D#!j#1+snvuTz3TlbE%c8T{OL9&gHt zgp}85TEDUDGwYkyIG|%w16~)RJ2sZ!6)d`AlMt}Z!5B)LN$~U<99v+_40|>|>!D#b z2I@K;Jg3+IX;-lFi|IG?AMdfQrC-tadGRx6NI;6}P-Gq0D>ga?kgz92m{WMmMJL*) zceJ$*O-I^Vhu-h#Yn{zrLmSzfuLBo1U}7tE+jn0(j0R0hVOqopbT4dO#cUM4;}r$c zXa{*TkOy~zmP!Lm92e_-;b3)h)V|sI^knAsT(l$NNd#1;oK&hq0`Ff>zgNm?Bxhxv z@wi}s)(XShJ;gy@KRzxOsR`Gw8HSr(bG@%)J{IezdtMCMCsXcwPbM+PQb1Jn%xe{h zR@_N$c|#sJQYY(~edV#`jsk7T{nuR$q3SX!aZiJCb)O`0re>QW3}y79`A7qe=U(~c zEiIVg6s1D%=1N;wbr*VtB$x2QYrN7MRH~84!!pg>DvPc`vBACl97mww{i@ct*?|$k zj|_C6$ovdkDyesfV39%~qGGHY>NBrgrxkWH$o)b;#VHEhy63E?!DRS%HNXC83Z4C5 z*E|4#mj>=XQDj`^kY;|;aZ?M-x@&X0LO>>xWNqDAooyfpHNA(kCG-_8&4-W1SYApX zTE`9BYYIp$-5^e6SY9uyD6hy^Im(6ZTD8EX5NsR8hy<4ylqbs5p9B74PAkm)OxT~V zPJKpT>Rde^wq**AB0;;&TXO?v?G5fUKKFSzRUvhkB{$QlmvYDKN8@ZhVm4F(hqCro z?3qh~yEh~$Dr#RLt0ccqz@03A`=&gX*Cc}|-C%++b#2FE^-C$r{*H)eh-bb!J{+my z%3~M!1Z7+r_$Dm!5-h}xx{<{(esd3*^~ii|UM6AGI{h-q`ZPzI+XA-mOMr>ZDy30@ z1AnZ4T=_2tB#rfFnqjr7%U6^f)>)e9v+(KZymwE^bq$yzaiQ~;)E!oj#bK$mQM7Ag zuB2}l&@yicEcin%mAQrn?%j7!EuAjLWR$D#s~Q;S49?Z`?>l!_ylWr#kkF}QeR`IE z-(5hq^^f*8%eL`4;SGoe2m^akglmH%&144iH8m>~^y?M$1|JWV8BC^hThuMOG&$yVxWqCT3wt;4}r7}d;5!?N9)5py^@1xoO#@B_K_CTd&tFAsh0*q z_0Dx0b~XrsPRipar^H^Tk}*XhN+|@TlyJ?{b=h)YP@NJEN#TqK`w49!CJg@81E=uT z8HYxt1?C63oAU+(O^d-PXfx0U!9j$kkjS9T9-9Jw@KdG#>1gr82O(vC^&EMQy5Gao z_pwa3_ZD4t(-WH`ePehszW0sa)OiD$r{Q=9J7p(Q*cCP(eokOl*$OEpll#Xj7++8~|%fM+VIsC$4R9_1 z5u44lO|DNe^uxNC&F6X((=ozZ;!PGNwCUh55Ied~JgqNn*334!Ac^_0@m8q;TM~>) z+EP_pXdCN_4IG$r3#$aJ_E{4^BqMAg(GdaPlzW?U!gMA^Ob53{|4E^N`1KsyW;8y%ZKeWlJFKfL(y;4WpaSBw*_H_ VonYd?HU<%!Ee$5V3Fp>Y{~NndA-Dhl literal 0 HcmV?d00001 diff --git a/artifacts/teaboy-os/src/App.tsx b/artifacts/teaboy-os/src/App.tsx new file mode 100644 index 00000000..7a9d12f0 --- /dev/null +++ b/artifacts/teaboy-os/src/App.tsx @@ -0,0 +1,54 @@ +import { Switch, Route, Router as WouterRouter } from "wouter"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Toaster } from "@/components/ui/toaster"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { AuthProvider } from "@/contexts/AuthContext"; +import NotFound from "@/pages/not-found"; +import LoginPage from "@/pages/login"; +import RegisterPage from "@/pages/register"; +import HomePage from "@/pages/home"; +import ServicesPage from "@/pages/services"; +import ChatPage from "@/pages/chat"; +import NotificationsPage from "@/pages/notifications"; +import AdminPage from "@/pages/admin"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + staleTime: 30_000, + }, + }, +}); + +function Router() { + return ( + + + + + + + + + + + + + ); +} + +function App() { + return ( + + + + + + + + + ); +} + +export default App; diff --git a/artifacts/teaboy-os/src/components/ui/accordion.tsx b/artifacts/teaboy-os/src/components/ui/accordion.tsx new file mode 100644 index 00000000..e1797c93 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/accordion.tsx @@ -0,0 +1,55 @@ +import * as React from "react" +import * as AccordionPrimitive from "@radix-ui/react-accordion" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180", + className + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx b/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..fa2b4429 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/alert-dialog.tsx @@ -0,0 +1,139 @@ +import * as React from "react" +import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog" + +import { cn } from "@/lib/utils" +import { buttonVariants } from "@/components/ui/button" + +const AlertDialog = AlertDialogPrimitive.Root + +const AlertDialogTrigger = AlertDialogPrimitive.Trigger + +const AlertDialogPortal = AlertDialogPrimitive.Portal + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName + +const AlertDialogContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + +)) +AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName + +const AlertDialogHeader = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.HTMLAttributes) => ( +
+) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = + AlertDialogPrimitive.Description.displayName + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/artifacts/teaboy-os/src/components/ui/alert.tsx b/artifacts/teaboy-os/src/components/ui/alert.tsx new file mode 100644 index 00000000..5afd41d1 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/alert.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7", + { + variants: { + variant: { + default: "bg-background text-foreground", + destructive: + "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Alert = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes & VariantProps +>(({ className, variant, ...props }, ref) => ( +
+)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx b/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx new file mode 100644 index 00000000..c4abbf37 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,5 @@ +import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio" + +const AspectRatio = AspectRatioPrimitive.Root + +export { AspectRatio } diff --git a/artifacts/teaboy-os/src/components/ui/avatar.tsx b/artifacts/teaboy-os/src/components/ui/avatar.tsx new file mode 100644 index 00000000..51e507ba --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/avatar.tsx @@ -0,0 +1,50 @@ +"use client" + +import * as React from "react" +import * as AvatarPrimitive from "@radix-ui/react-avatar" + +import { cn } from "@/lib/utils" + +const Avatar = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/artifacts/teaboy-os/src/components/ui/badge.tsx b/artifacts/teaboy-os/src/components/ui/badge.tsx new file mode 100644 index 00000000..3f036658 --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/badge.tsx @@ -0,0 +1,43 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const badgeVariants = cva( + // @replit + // Whitespace-nowrap: Badges should never wrap. + "whitespace-nowrap inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2" + + " hover-elevate ", + { + variants: { + variant: { + default: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-primary text-primary-foreground shadow-xs", + secondary: + // @replit no hover because we use hover-elevate + "border-transparent bg-secondary text-secondary-foreground", + destructive: + // @replit shadow-xs instead of shadow, no hover because we use hover-elevate + "border-transparent bg-destructive text-destructive-foreground shadow-xs", + // @replit shadow-xs" - use badge outline variable + outline: "text-foreground border [border-color:var(--badge-outline)]", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx b/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx new file mode 100644 index 00000000..60e6c96f --- /dev/null +++ b/artifacts/teaboy-os/src/components/ui/breadcrumb.tsx @@ -0,0 +1,115 @@ +import * as React from "react" +import { Slot } from "@radix-ui/react-slot" +import { ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Breadcrumb = React.forwardRef< + HTMLElement, + React.ComponentPropsWithoutRef<"nav"> & { + separator?: React.ReactNode + } +>(({ ...props }, ref) =>