20 lines
14 KiB
JSON
20 lines
14 KiB
JSON
{
|
|
"id": "web-db-user",
|
|
"name": "Web App (db,user)",
|
|
"description": "Full-stack web template with database + user flows",
|
|
"capabilities": [
|
|
"server",
|
|
"db",
|
|
"user"
|
|
],
|
|
"files": {
|
|
"package.json": "{\n \"name\": \"retrotoon-studio\",\n \"version\": \"1.0.0\",\n \"type\": \"module\",\n \"license\": \"MIT\",\n \"scripts\": {\n \"dev\": \"NODE_ENV=development tsx watch server/_core/index.ts\",\n \"build\": \"vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist\",\n \"start\": \"NODE_ENV=production node dist/index.js\",\n \"check\": \"tsc --noEmit\",\n \"format\": \"prettier --write .\",\n \"test\": \"vitest run\",\n \"db:push\": \"drizzle-kit generate && drizzle-kit migrate\"\n },\n \"dependencies\": {\n \"@aws-sdk/client-s3\": \"^3.693.0\",\n \"@aws-sdk/s3-request-presigner\": \"^3.693.0\",\n \"@hookform/resolvers\": \"^5.2.2\",\n \"@radix-ui/react-accordion\": \"^1.2.12\",\n \"@radix-ui/react-alert-dialog\": \"^1.1.15\",\n \"@radix-ui/react-aspect-ratio\": \"^1.1.7\",\n \"@radix-ui/react-avatar\": \"^1.1.10\",\n \"@radix-ui/react-checkbox\": \"^1.3.3\",\n \"@radix-ui/react-collapsible\": \"^1.1.12\",\n \"@radix-ui/react-context-menu\": \"^2.2.16\",\n \"@radix-ui/react-dialog\": \"^1.1.15\",\n \"@radix-ui/react-dropdown-menu\": \"^2.1.16\",\n \"@radix-ui/react-hover-card\": \"^1.1.15\",\n \"@radix-ui/react-label\": \"^2.1.7\",\n \"@radix-ui/react-menubar\": \"^1.1.16\",\n \"@radix-ui/react-navigation-menu\": \"^1.2.14\",\n \"@radix-ui/react-popover\": \"^1.1.15\",\n \"@radix-ui/react-progress\": \"^1.1.7\",\n \"@radix-ui/react-radio-group\": \"^1.3.8\",\n \"@radix-ui/react-scroll-area\": \"^1.2.10\",\n \"@radix-ui/react-select\": \"^2.2.6\",\n \"@radix-ui/react-separator\": \"^1.1.7\",\n \"@radix-ui/react-slider\": \"^1.3.6\",\n \"@radix-ui/react-slot\": \"^1.2.3\",\n \"@radix-ui/react-switch\": \"^1.2.6\",\n \"@radix-ui/react-tabs\": \"^1.1.13\",\n \"@radix-ui/react-toggle\": \"^1.1.10\",\n \"@radix-ui/react-toggle-group\": \"^1.1.11\",\n \"@radix-ui/react-tooltip\": \"^1.2.8\",\n \"@tanstack/react-query\": \"^5.90.2\",\n \"@trpc/client\": \"^11.6.0\",\n \"@trpc/react-query\": \"^11.6.0\",\n \"@trpc/server\": \"^11.6.0\",\n \"axios\": \"^1.12.0\",\n \"class-variance-authority\": \"^0.7.1\",\n \"clsx\": \"^2.1.1\",\n \"cmdk\": \"^1.1.1\",\n \"cookie\": \"^1.0.2\",\n \"date-fns\": \"^4.1.0\",\n \"dotenv\": \"^17.2.2\",\n \"drizzle-orm\": \"^0.44.5\",\n \"embla-carousel-react\": \"^8.6.0\",\n \"express\": \"^4.21.2\",\n \"framer-motion\": \"^12.23.22\",\n \"input-otp\": \"^1.4.2\",\n \"jose\": \"6.1.0\",\n \"lucide-react\": \"^0.453.0\",\n \"mysql2\": \"^3.15.0\",\n \"nanoid\": \"^5.1.5\",\n \"next-themes\": \"^0.4.6\",\n \"react\": \"^19.2.1\",\n \"react-day-picker\": \"^9.11.1\",\n \"react-dom\": \"^19.2.1\",\n \"react-hook-form\": \"^7.64.0\",\n \"react-resizable-panels\": \"^3.0.6\",\n \"recharts\": \"^2.15.2\",\n \"sonner\": \"^2.0.7\",\n \"streamdown\": \"^1.4.0\",\n \"superjson\": \"^1.13.3\",\n \"tailwind-merge\": \"^3.3.1\",\n \"tailwindcss-animate\": \"^1.0.7\",\n \"vaul\": \"^1.1.2\",\n \"wouter\": \"^3.3.5\",\n \"zod\": \"^4.1.12\"\n },\n \"devDependencies\": {\n \"@builder.io/vite-plugin-jsx-loc\": \"^0.1.1\",\n \"@tailwindcss/typography\": \"^0.5.15\",\n \"@tailwindcss/vite\": \"^4.1.3\",\n \"@types/express\": \"4.17.21\",\n \"@types/google.maps\": \"^3.58.1\",\n \"@types/node\": \"^24.7.0\",\n \"@types/react\": \"^19.2.1\",\n \"@types/react-dom\": \"^19.2.1\",\n \"@vitejs/plugin-react\": \"^5.0.4\",\n \"add\": \"^2.0.6\",\n \"autoprefixer\": \"^10.4.20\",\n \"drizzle-kit\": \"^0.31.4\",\n \"esbuild\": \"^0.25.0\",\n \"pnpm\": \"^10.15.1\",\n \"postcss\": \"^8.4.47\",\n \"prettier\": \"^3.6.2\",\n \"tailwindcss\": \"^4.1.14\",\n \"tsx\": \"^4.19.1\",\n \"tw-animate-css\": \"^1.4.0\",\n \"typescript\": \"5.9.3\",\n \"vite\": \"^7.1.7\",\n \"vite-plugin-manus-runtime\": \"^0.0.57\",\n \"vitest\": \"^2.1.4\"\n },\n \"packageManager\": \"pnpm@10.4.1+sha512.c753b6c3ad7afa13af388fa6d808035a008e30ea9993f58c6663e2bc5ff21679aa834db094987129aa4d488b86df57f7b634981b2f827cdcacc698cc0cfb88af\",\n \"pnpm\": {\n \"patchedDependencies\": {\n \"wouter@3.7.1\": \"patches/wouter@3.7.1.patch\"\n },\n \"overrides\": {\n \"tailwindcss>nanoid\": \"3.3.7\"\n }\n }\n}",
|
|
"drizzle/schema.ts": "import { int, mysqlEnum, mysqlTable, text, timestamp, varchar } from \"drizzle-orm/mysql-core\";\n\n/**\n * Core user table backing auth flow.\n * Extend this file with additional tables as your product grows.\n * Columns use camelCase to match both database fields and generated types.\n */\nexport const users = mysqlTable(\"users\", {\n /**\n * Surrogate primary key. Auto-incremented numeric value managed by the database.\n * Use this for relations between tables.\n */\n id: int(\"id\").autoincrement().primaryKey(),\n /** Manus OAuth identifier (openId) returned from the OAuth callback. Unique per user. */\n openId: varchar(\"openId\", { length: 64 }).notNull().unique(),\n name: text(\"name\"),\n email: varchar(\"email\", { length: 320 }),\n loginMethod: varchar(\"loginMethod\", { length: 64 }),\n role: mysqlEnum(\"role\", [\"user\", \"admin\"]).default(\"user\").notNull(),\n createdAt: timestamp(\"createdAt\").defaultNow().notNull(),\n updatedAt: timestamp(\"updatedAt\").defaultNow().onUpdateNow().notNull(),\n lastSignedIn: timestamp(\"lastSignedIn\").defaultNow().notNull(),\n});\n\nexport type User = typeof users.$inferSelect;\nexport type InsertUser = typeof users.$inferInsert;\n\n// TODO: Add your tables here",
|
|
"server/db.ts": "import { eq } from \"drizzle-orm\";\nimport { drizzle } from \"drizzle-orm/mysql2\";\nimport { InsertUser, users } from \"../drizzle/schema\";\nimport { ENV } from './_core/env';\n\nlet _db: ReturnType<typeof drizzle> | null = null;\n\n// Lazily create the drizzle instance so local tooling can run without a DB.\nexport async function getDb() {\n if (!_db && process.env.DATABASE_URL) {\n try {\n _db = drizzle(process.env.DATABASE_URL);\n } catch (error) {\n console.warn(\"[Database] Failed to connect:\", error);\n _db = null;\n }\n }\n return _db;\n}\n\nexport async function upsertUser(user: InsertUser): Promise<void> {\n if (!user.openId) {\n throw new Error(\"User openId is required for upsert\");\n }\n\n const db = await getDb();\n if (!db) {\n console.warn(\"[Database] Cannot upsert user: database not available\");\n return;\n }\n\n try {\n const values: InsertUser = {\n openId: user.openId,\n };\n const updateSet: Record<string, unknown> = {};\n\n const textFields = [\"name\", \"email\", \"loginMethod\"] as const;\n type TextField = (typeof textFields)[number];\n\n const assignNullable = (field: TextField) => {\n const value = user[field];\n if (value === undefined) return;\n const normalized = value ?? null;\n values[field] = normalized;\n updateSet[field] = normalized;\n };\n\n textFields.forEach(assignNullable);\n\n if (user.lastSignedIn !== undefined) {\n values.lastSignedIn = user.lastSignedIn;\n updateSet.lastSignedIn = user.lastSignedIn;\n }\n if (user.role !== undefined) {\n values.role = user.role;\n updateSet.role = user.role;\n } else if (user.openId === ENV.ownerOpenId) {\n values.role = 'admin';\n updateSet.role = 'admin';\n }\n\n if (!values.lastSignedIn) {\n values.lastSignedIn = new Date();\n }\n\n if (Object.keys(updateSet).length === 0) {\n updateSet.lastSignedIn = new Date();\n }\n\n await db.insert(users).values(values).onDuplicateKeyUpdate({\n set: updateSet,\n });\n } catch (error) {\n console.error(\"[Database] Failed to upsert user:\", error);\n throw error;\n }\n}\n\nexport async function getUserByOpenId(openId: string) {\n const db = await getDb();\n if (!db) {\n console.warn(\"[Database] Cannot get user: database not available\");\n return undefined;\n }\n\n const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);\n\n return result.length > 0 ? result[0] : undefined;\n}\n\n// TODO: add feature queries here as your schema grows.",
|
|
"server/routers.ts": "import { COOKIE_NAME } from \"@shared/const\";\nimport { getSessionCookieOptions } from \"./_core/cookies\";\nimport { systemRouter } from \"./_core/systemRouter\";\nimport { publicProcedure, router } from \"./_core/trpc\";\n\nexport const appRouter = router({\n // if you need to use socket.io, read and register route in server/_core/index.ts, all api should start with '/api/' so that the gateway can route correctly\n system: systemRouter,\n auth: router({\n me: publicProcedure.query(opts => opts.ctx.user),\n logout: publicProcedure.mutation(({ ctx }) => {\n const cookieOptions = getSessionCookieOptions(ctx.req);\n ctx.res.clearCookie(COOKIE_NAME, { ...cookieOptions, maxAge: -1 });\n return {\n success: true,\n } as const;\n }),\n }),\n\n // TODO: add feature routers here, e.g.\n // todo: router({\n // list: protectedProcedure.query(({ ctx }) =>\n // db.getUserTodos(ctx.user.id)\n // ),\n // }),\n});\n\nexport type AppRouter = typeof appRouter;",
|
|
"client/src/App.tsx": "import { Toaster } from \"@/components/ui/sonner\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport NotFound from \"@/pages/NotFound\";\nimport { Route, Switch } from \"wouter\";\nimport ErrorBoundary from \"./components/ErrorBoundary\";\nimport { ThemeProvider } from \"./contexts/ThemeContext\";\nimport Home from \"./pages/Home\";\n\nfunction Router() {\n // make sure to consider if you need authentication for certain routes\n return (\n <Switch>\n <Route path={\"/\"} component={Home} />\n <Route path={\"/404\"} component={NotFound} />\n {/* Final fallback route */}\n <Route component={NotFound} />\n </Switch>\n );\n}\n\n// NOTE: About Theme\n// - First choose a default theme according to your design style (dark or light bg), than change color palette in index.css\n// to keep consistent foreground/background color across components\n// - If you want to make theme switchable, pass `switchable` ThemeProvider and use `useTheme` hook\n\nfunction App() {\n return (\n <ErrorBoundary>\n <ThemeProvider\n defaultTheme=\"light\"\n // switchable\n >\n <TooltipProvider>\n <Toaster />\n <Router />\n </TooltipProvider>\n </ThemeProvider>\n </ErrorBoundary>\n );\n}\n\nexport default App;",
|
|
"client/src/lib/trpc.ts": "import { createTRPCReact } from \"@trpc/react-query\";\nimport type { AppRouter } from \"../../../server/routers\";\n\nexport const trpc = createTRPCReact<AppRouter>();",
|
|
"client/src/pages/Home.tsx": "import { useAuth } from \"@/_core/hooks/useAuth\";\nimport { Button } from \"@/components/ui/button\";\nimport { Loader2 } from \"lucide-react\";\nimport { getLoginUrl } from \"@/const\";\nimport { Streamdown } from 'streamdown';\n\n/**\n * All content in this page are only for example, replace with your own feature implementation\n * When building pages, remember your instructions in Frontend Workflow, Frontend Best Practices, Design Guide and Common Pitfalls\n */\nexport default function Home() {\n // The userAuth hooks provides authentication state\n // To implement login/logout functionality, simply call logout() or redirect to getLoginUrl()\n let { user, loading, error, isAuthenticated, logout } = useAuth();\n\n // If theme is switchable in App.tsx, we can implement theme toggling like this:\n // const { theme, toggleTheme } = useTheme();\n\n return (\n <div className=\"min-h-screen flex flex-col\">\n <main>\n {/* Example: lucide-react for icons */}\n <Loader2 className=\"animate-spin\" />\n Example Page\n {/* Example: Streamdown for markdown rendering */}\n <Streamdown>Any **markdown** content</Streamdown>\n <Button variant=\"default\">Example Button</Button>\n </main>\n </div>\n );\n}",
|
|
"server/auth.logout.test.ts": "import { describe, expect, it } from \"vitest\";\nimport { appRouter } from \"./routers\";\nimport { COOKIE_NAME } from \"../shared/const\";\nimport type { TrpcContext } from \"./_core/context\";\n\ntype CookieCall = {\n name: string;\n options: Record<string, unknown>;\n};\n\ntype AuthenticatedUser = NonNullable<TrpcContext[\"user\"]>;\n\nfunction createAuthContext(): { ctx: TrpcContext; clearedCookies: CookieCall[] } {\n const clearedCookies: CookieCall[] = [];\n\n const user: AuthenticatedUser = {\n id: 1,\n openId: \"sample-user\",\n email: \"sample@example.com\",\n name: \"Sample User\",\n loginMethod: \"manus\",\n role: \"user\",\n createdAt: new Date(),\n updatedAt: new Date(),\n lastSignedIn: new Date(),\n };\n\n const ctx: TrpcContext = {\n user,\n req: {\n protocol: \"https\",\n headers: {},\n } as TrpcContext[\"req\"],\n res: {\n clearCookie: (name: string, options: Record<string, unknown>) => {\n clearedCookies.push({ name, options });\n },\n } as TrpcContext[\"res\"],\n };\n\n return { ctx, clearedCookies };\n}\n\ndescribe(\"auth.logout\", () => {\n it(\"clears the session cookie and reports success\", async () => {\n const { ctx, clearedCookies } = createAuthContext();\n const caller = appRouter.createCaller(ctx);\n\n const result = await caller.auth.logout();\n\n expect(result).toEqual({ success: true });\n expect(clearedCookies).toHaveLength(1);\n expect(clearedCookies[0]?.name).toBe(COOKIE_NAME);\n expect(clearedCookies[0]?.options).toMatchObject({\n maxAge: -1,\n secure: true,\n sameSite: \"none\",\n httpOnly: true,\n path: \"/\",\n });\n });\n});"
|
|
}
|
|
}
|