create-m5kdev 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/dist/src/__tests__/create.smoke.test.d.ts +1 -0
  2. package/dist/src/__tests__/create.smoke.test.js +56 -0
  3. package/dist/src/__tests__/create.test.d.ts +1 -0
  4. package/dist/src/__tests__/create.test.js +55 -0
  5. package/dist/src/__tests__/runCli.test.d.ts +1 -0
  6. package/dist/src/__tests__/runCli.test.js +44 -0
  7. package/dist/src/__tests__/strings.test.d.ts +1 -0
  8. package/dist/src/__tests__/strings.test.js +24 -0
  9. package/dist/src/constants.d.ts +3 -0
  10. package/dist/src/constants.js +9 -0
  11. package/dist/src/create.d.ts +7 -0
  12. package/dist/src/create.js +36 -0
  13. package/dist/src/fs.d.ts +5 -0
  14. package/dist/src/fs.js +60 -0
  15. package/dist/src/index.d.ts +2 -0
  16. package/dist/src/index.js +9 -0
  17. package/dist/src/paths.d.ts +1 -0
  18. package/dist/src/paths.js +14 -0
  19. package/dist/src/prompts.d.ts +2 -0
  20. package/dist/src/prompts.js +55 -0
  21. package/dist/src/runCli.d.ts +9 -0
  22. package/dist/src/runCli.js +107 -0
  23. package/dist/src/strings.d.ts +6 -0
  24. package/dist/src/strings.js +47 -0
  25. package/dist/src/types.d.ts +18 -0
  26. package/dist/src/types.js +2 -0
  27. package/dist/tsconfig.tsbuildinfo +1 -0
  28. package/package.json +38 -0
  29. package/templates/minimal-app/.gitignore.tpl +9 -0
  30. package/templates/minimal-app/AGENTS.md.tpl +29 -0
  31. package/templates/minimal-app/README.md.tpl +35 -0
  32. package/templates/minimal-app/apps/email/package.json.tpl +27 -0
  33. package/templates/minimal-app/apps/email/src/components/BaseEmail.tsx.tpl +117 -0
  34. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +26 -0
  35. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +31 -0
  36. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +25 -0
  37. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +26 -0
  38. package/templates/minimal-app/apps/email/src/index.ts.tpl +32 -0
  39. package/templates/minimal-app/apps/email/tsconfig.json.tpl +11 -0
  40. package/templates/minimal-app/apps/server/AGENTS.md.tpl +30 -0
  41. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +111 -0
  42. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +18 -0
  43. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +38 -0
  44. package/templates/minimal-app/apps/server/package.json.tpl +50 -0
  45. package/templates/minimal-app/apps/server/src/db.ts.tpl +33 -0
  46. package/templates/minimal-app/apps/server/src/index.ts.tpl +34 -0
  47. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +172 -0
  48. package/templates/minimal-app/apps/server/src/lib/localEmailService.ts.tpl +58 -0
  49. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +23 -0
  50. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +106 -0
  51. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +150 -0
  52. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +44 -0
  53. package/templates/minimal-app/apps/server/src/repository.ts.tpl +6 -0
  54. package/templates/minimal-app/apps/server/src/service.ts.tpl +12 -0
  55. package/templates/minimal-app/apps/server/src/trpc.ts.tpl +11 -0
  56. package/templates/minimal-app/apps/server/src/types.ts.tpl +3 -0
  57. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +27 -0
  58. package/templates/minimal-app/apps/server/tsconfig.json.tpl +13 -0
  59. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -0
  60. package/templates/minimal-app/apps/shared/.env.example.tpl +17 -0
  61. package/templates/minimal-app/apps/shared/.env.tpl +19 -0
  62. package/templates/minimal-app/apps/shared/package.json.tpl +24 -0
  63. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -0
  64. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +76 -0
  65. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +12 -0
  66. package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +18 -0
  67. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -0
  68. package/templates/minimal-app/apps/webapp/package.json.tpl +52 -0
  69. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +13 -0
  70. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +139 -0
  71. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +28 -0
  72. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +53 -0
  73. package/templates/minimal-app/apps/webapp/src/components/TrpcQueryProvider.tsx.tpl +61 -0
  74. package/templates/minimal-app/apps/webapp/src/hero.ts.tpl +99 -0
  75. package/templates/minimal-app/apps/webapp/src/index.css.tpl +75 -0
  76. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -0
  77. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +650 -0
  78. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -0
  79. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +4 -0
  80. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -0
  81. package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +107 -0
  82. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +16 -0
  83. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -0
  84. package/templates/minimal-app/biome.json.tpl +76 -0
  85. package/templates/minimal-app/package.json.tpl +21 -0
  86. package/templates/minimal-app/pnpm-workspace.yaml.tpl +58 -0
  87. package/templates/minimal-app/turbo.json.tpl +26 -0
@@ -0,0 +1,106 @@
1
+ import type {
2
+ PostSchema,
3
+ PostsListInputSchema,
4
+ PostsListOutputSchema,
5
+ } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
6
+ import { BaseTableRepository } from "@m5kdev/backend/modules/base/base.repository";
7
+ import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
8
+ import { and, asc, count, desc, eq, isNull, like, ne, or } from "drizzle-orm";
9
+ import { ok } from "neverthrow";
10
+ import type { Orm, Schema } from "../../db";
11
+
12
+ export class PostsRepository extends BaseTableRepository<
13
+ Orm,
14
+ Schema,
15
+ Record<string, never>,
16
+ Schema["posts"]
17
+ > {
18
+ async list(input: PostsListInputSchema = {}): ServerResultAsync<PostsListOutputSchema> {
19
+ return this.throwableAsync(async () => {
20
+ const page = input.page ?? 1;
21
+ const limit = input.limit ?? 6;
22
+ const search = input.search?.trim();
23
+ const conditions = [isNull(this.table.deletedAt)];
24
+
25
+ if (input.status) {
26
+ conditions.push(eq(this.table.status, input.status));
27
+ }
28
+
29
+ if (search) {
30
+ const pattern = `%${search}%`;
31
+ const searchCondition = or(
32
+ like(this.table.title, pattern),
33
+ like(this.table.slug, pattern),
34
+ like(this.table.excerpt, pattern),
35
+ like(this.table.content, pattern)
36
+ );
37
+
38
+ if (searchCondition) {
39
+ conditions.push(searchCondition);
40
+ }
41
+ }
42
+
43
+ const whereClause = and(...conditions);
44
+ const ordering =
45
+ input.sort === "title"
46
+ ? input.order === "asc"
47
+ ? asc(this.table.title)
48
+ : desc(this.table.title)
49
+ : input.sort === "publishedAt"
50
+ ? input.order === "asc"
51
+ ? asc(this.table.publishedAt)
52
+ : desc(this.table.publishedAt)
53
+ : input.order === "asc"
54
+ ? asc(this.table.updatedAt)
55
+ : desc(this.table.updatedAt);
56
+
57
+ const rows = await this.orm
58
+ .select()
59
+ .from(this.table)
60
+ .where(whereClause)
61
+ .orderBy(ordering, desc(this.table.createdAt))
62
+ .limit(limit)
63
+ .offset((page - 1) * limit);
64
+
65
+ const [totalRow] = await this.orm
66
+ .select({ count: count() })
67
+ .from(this.table)
68
+ .where(whereClause);
69
+
70
+ return ok({
71
+ rows: rows as PostSchema[],
72
+ total: totalRow?.count ?? 0,
73
+ });
74
+ });
75
+ }
76
+
77
+ async findBySlug(slug: string, excludeId?: string) {
78
+ return this.throwableAsync(async () => {
79
+ const whereClause = excludeId
80
+ ? and(eq(this.table.slug, slug), ne(this.table.id, excludeId), isNull(this.table.deletedAt))
81
+ : and(eq(this.table.slug, slug), isNull(this.table.deletedAt));
82
+
83
+ const [row] = await this.orm.select().from(this.table).where(whereClause).limit(1);
84
+ return ok(row);
85
+ });
86
+ }
87
+
88
+ async resolveUniqueSlug(candidate: string, excludeId?: string) {
89
+ let slug = candidate;
90
+ let suffix = 2;
91
+
92
+ while (true) {
93
+ const existing = await this.findBySlug(slug, excludeId);
94
+ if (existing.isErr()) {
95
+ return existing;
96
+ }
97
+
98
+ if (!existing.value) {
99
+ return ok(slug);
100
+ }
101
+
102
+ slug = `${candidate}-${suffix}`;
103
+ suffix += 1;
104
+ }
105
+ }
106
+ }
@@ -0,0 +1,150 @@
1
+ import type {
2
+ PostCreateInputSchema,
3
+ PostCreateOutputSchema,
4
+ PostPublishInputSchema,
5
+ PostPublishOutputSchema,
6
+ PostSoftDeleteInputSchema,
7
+ PostSoftDeleteOutputSchema,
8
+ PostsListInputSchema,
9
+ PostsListOutputSchema,
10
+ PostUpdateInputSchema,
11
+ PostUpdateOutputSchema,
12
+ } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
13
+ import { BaseService } from "@m5kdev/backend/modules/base/base.service";
14
+ import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
15
+ import { err, ok } from "neverthrow";
16
+ import type { PostsRepository } from "./posts.repository";
17
+
18
+ type RequestContext = {
19
+ session: {
20
+ activeOrganizationId?: string | null;
21
+ activeTeamId?: string | null;
22
+ };
23
+ user: {
24
+ id: string;
25
+ };
26
+ };
27
+
28
+ export class PostsService extends BaseService<{ posts: PostsRepository }, Record<string, never>> {
29
+ async list(
30
+ input: PostsListInputSchema,
31
+ _ctx: RequestContext
32
+ ): ServerResultAsync<PostsListOutputSchema> {
33
+ return this.repository.posts.list(input);
34
+ }
35
+
36
+ async create(
37
+ input: PostCreateInputSchema,
38
+ ctx: RequestContext
39
+ ): ServerResultAsync<PostCreateOutputSchema> {
40
+ const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
41
+ this.slugify(input.slug ?? input.title)
42
+ );
43
+ if (uniqueSlug.isErr()) {
44
+ return err(uniqueSlug.error);
45
+ }
46
+
47
+ return this.repository.posts.create({
48
+ authorUserId: ctx.user.id,
49
+ organizationId: ctx.session.activeOrganizationId ?? null,
50
+ teamId: ctx.session.activeTeamId ?? null,
51
+ title: input.title.trim(),
52
+ slug: uniqueSlug.value,
53
+ excerpt: this.createExcerpt(input.excerpt, input.content),
54
+ content: input.content.trim(),
55
+ status: "draft",
56
+ }) as ServerResultAsync<PostCreateOutputSchema>;
57
+ }
58
+
59
+ async update(
60
+ input: PostUpdateInputSchema,
61
+ _ctx: RequestContext
62
+ ): ServerResultAsync<PostUpdateOutputSchema> {
63
+ const current = await this.repository.posts.findById(input.id);
64
+ if (current.isErr()) {
65
+ return err(current.error);
66
+ }
67
+ if (!current.value || current.value.deletedAt) {
68
+ return this.error("NOT_FOUND", "Post not found");
69
+ }
70
+
71
+ const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
72
+ this.slugify(input.slug ?? input.title),
73
+ input.id
74
+ );
75
+ if (uniqueSlug.isErr()) {
76
+ return err(uniqueSlug.error);
77
+ }
78
+
79
+ return this.repository.posts.update({
80
+ id: input.id,
81
+ title: input.title.trim(),
82
+ slug: uniqueSlug.value,
83
+ excerpt: this.createExcerpt(input.excerpt, input.content),
84
+ content: input.content.trim(),
85
+ }) as ServerResultAsync<PostUpdateOutputSchema>;
86
+ }
87
+
88
+ async publish(
89
+ input: PostPublishInputSchema,
90
+ _ctx: RequestContext
91
+ ): ServerResultAsync<PostPublishOutputSchema> {
92
+ const current = await this.repository.posts.findById(input.id);
93
+ if (current.isErr()) {
94
+ return err(current.error);
95
+ }
96
+ if (!current.value || current.value.deletedAt) {
97
+ return this.error("NOT_FOUND", "Post not found");
98
+ }
99
+
100
+ return this.repository.posts.update({
101
+ id: input.id,
102
+ status: "published",
103
+ publishedAt: current.value.publishedAt ?? new Date(),
104
+ }) as ServerResultAsync<PostPublishOutputSchema>;
105
+ }
106
+
107
+ async softDelete(
108
+ input: PostSoftDeleteInputSchema,
109
+ _ctx: RequestContext
110
+ ): ServerResultAsync<PostSoftDeleteOutputSchema> {
111
+ const current = await this.repository.posts.findById(input.id);
112
+ if (current.isErr()) {
113
+ return err(current.error);
114
+ }
115
+ if (!current.value || current.value.deletedAt) {
116
+ return this.error("NOT_FOUND", "Post not found");
117
+ }
118
+
119
+ const updated = await this.repository.posts.update({
120
+ id: input.id,
121
+ deletedAt: new Date(),
122
+ });
123
+
124
+ if (updated.isErr()) {
125
+ return err(updated.error);
126
+ }
127
+
128
+ return ok({ id: updated.value.id });
129
+ }
130
+
131
+ private slugify(value: string): string {
132
+ const slug = value
133
+ .trim()
134
+ .toLowerCase()
135
+ .replace(/[^a-z0-9]+/g, "-")
136
+ .replace(/^-+/g, "")
137
+ .replace(/-+$/g, "")
138
+ .replace(/-{2,}/g, "-");
139
+
140
+ return slug || "post";
141
+ }
142
+
143
+ private createExcerpt(excerpt: string | undefined, content: string): string {
144
+ if (excerpt?.trim()) {
145
+ return excerpt.trim();
146
+ }
147
+
148
+ return content.replace(/\s+/g, " ").trim().slice(0, 180);
149
+ }
150
+ }
@@ -0,0 +1,44 @@
1
+ import {
2
+ postCreateInputSchema,
3
+ postCreateOutputSchema,
4
+ postPublishInputSchema,
5
+ postPublishOutputSchema,
6
+ postSoftDeleteInputSchema,
7
+ postSoftDeleteOutputSchema,
8
+ postsListInputSchema,
9
+ postsListOutputSchema,
10
+ postUpdateInputSchema,
11
+ postUpdateOutputSchema,
12
+ } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
13
+ import { handleTRPCResult } from "@m5kdev/backend/utils/trpc";
14
+ import { postsService } from "../../service";
15
+ import { procedure, router } from "../../utils/trpc";
16
+
17
+ export const postsRouter = router({
18
+ list: procedure
19
+ .input(postsListInputSchema)
20
+ .output(postsListOutputSchema)
21
+ .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
22
+
23
+ create: procedure
24
+ .input(postCreateInputSchema)
25
+ .output(postCreateOutputSchema)
26
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
27
+
28
+ update: procedure
29
+ .input(postUpdateInputSchema)
30
+ .output(postUpdateOutputSchema)
31
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
32
+
33
+ publish: procedure
34
+ .input(postPublishInputSchema)
35
+ .output(postPublishOutputSchema)
36
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
37
+
38
+ softDelete: procedure
39
+ .input(postSoftDeleteInputSchema)
40
+ .output(postSoftDeleteOutputSchema)
41
+ .mutation(async ({ ctx, input }) =>
42
+ handleTRPCResult(await postsService.softDelete(input, ctx))
43
+ ),
44
+ });
@@ -0,0 +1,6 @@
1
+ import { AuthRepository } from "@m5kdev/backend/modules/auth/auth.repository";
2
+ import { orm, schema } from "./db";
3
+ import { PostsRepository } from "./modules/posts/posts.repository";
4
+
5
+ export const authRepository = new AuthRepository({ orm, schema });
6
+ export const postsRepository = new PostsRepository({ orm, schema, table: schema.posts });
@@ -0,0 +1,12 @@
1
+ import { AuthService } from "@m5kdev/backend/modules/auth/auth.service";
2
+ import { LocalEmailService } from "./lib/localEmailService";
3
+ import { PostsService } from "./modules/posts/posts.service";
4
+ import { authRepository, postsRepository } from "./repository";
5
+
6
+ export const emailService = new LocalEmailService({
7
+ appName: "{{APP_NAME}}",
8
+ appUrl: process.env.VITE_APP_URL ?? "http://localhost:5173",
9
+ });
10
+
11
+ export const authService = new AuthService({ auth: authRepository }, { email: emailService });
12
+ export const postsService = new PostsService({ posts: postsRepository }, {});
@@ -0,0 +1,11 @@
1
+ import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
2
+ import { postsRouter as posts } from "./modules/posts/posts.trpc";
3
+ import { router } from "./utils/trpc";
4
+
5
+ export const appRouter = router({
6
+ posts,
7
+ });
8
+
9
+ export type AppRouter = typeof appRouter;
10
+ export type RouterInputs = inferRouterInputs<AppRouter>;
11
+ export type RouterOutputs = inferRouterOutputs<AppRouter>;
@@ -0,0 +1,3 @@
1
+ import type { AppRouter, RouterInputs, RouterOutputs } from "./trpc";
2
+
3
+ export type { AppRouter, RouterInputs, RouterOutputs };
@@ -0,0 +1,27 @@
1
+ import {
2
+ type createAuthContext,
3
+ verifyProtectedProcedureContext,
4
+ } from "@m5kdev/backend/utils/trpc";
5
+ import { transformer } from "@m5kdev/commons/utils/trpc";
6
+ import { initTRPC } from "@trpc/server";
7
+
8
+ type Context = Awaited<ReturnType<ReturnType<typeof createAuthContext>>>;
9
+
10
+ const t = initTRPC.context<Context>().create({ transformer });
11
+
12
+ export const publicProcedure = t.procedure;
13
+
14
+ export const procedure = t.procedure.use(({ ctx, next }) => {
15
+ verifyProtectedProcedureContext(ctx);
16
+ return next({ ctx });
17
+ });
18
+
19
+ export const router = t.router;
20
+ export const mergeRouters = t.mergeRouters;
21
+
22
+ export const trpcObject = {
23
+ router,
24
+ privateProcedure: procedure,
25
+ adminProcedure: procedure,
26
+ publicProcedure,
27
+ };
@@ -0,0 +1,13 @@
1
+ {
2
+ "extends": "@m5kdev/config/tsconfig.node.json",
3
+ "rootDir": ".",
4
+ "compilerOptions": {
5
+ "esModuleInterop": true,
6
+ "importHelpers": true,
7
+ "jsx": "react-jsx",
8
+ "noEmit": true,
9
+ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
10
+ },
11
+ "exclude": ["node_modules"],
12
+ "include": ["src/**/*.ts", "drizzle/**/*.ts", "drizzle.config.ts", "tsup.config.ts"]
13
+ }
@@ -0,0 +1,9 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts"],
5
+ format: ["cjs"],
6
+ outDir: "dist",
7
+ sourcemap: true,
8
+ clean: true,
9
+ });
@@ -0,0 +1,17 @@
1
+ # Auth
2
+ BETTER_AUTH_SECRET=replace-me
3
+
4
+ # App URLs
5
+ VITE_APP_NAME={{APP_NAME}}
6
+ VITE_APP_URL=http://localhost:5173
7
+ VITE_SERVER_URL=http://localhost:8080
8
+
9
+ # Database
10
+ DATABASE_URL=file:./local.db
11
+ TURSO_DATABASE_URL=
12
+ TURSO_AUTH_TOKEN=
13
+
14
+ # Optional email and analytics providers
15
+ RESEND_API_KEY=
16
+ VITE_PUBLIC_POSTHOG_KEY=demo
17
+ VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
@@ -0,0 +1,19 @@
1
+ # Auth
2
+ BETTER_AUTH_SECRET={{BETTER_AUTH_SECRET}}
3
+
4
+ # App URLs
5
+ VITE_APP_NAME={{APP_NAME}}
6
+ VITE_APP_URL=http://localhost:5173
7
+ VITE_SERVER_URL=http://localhost:8080
8
+
9
+ # Database
10
+ DATABASE_URL=file:./local.db
11
+ TURSO_DATABASE_URL=
12
+ TURSO_AUTH_TOKEN=
13
+
14
+ # Local email delivery falls back to file output when unset
15
+ RESEND_API_KEY=
16
+
17
+ # Optional PostHog keys
18
+ VITE_PUBLIC_POSTHOG_KEY=demo
19
+ VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "{{PACKAGE_SCOPE}}/shared",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "build": "tsc --noEmit",
7
+ "lint": "biome check .",
8
+ "lint:fix": "biome check . --write",
9
+ "check-types": "tsc --noEmit"
10
+ },
11
+ "dependencies": {
12
+ "@m5kdev/commons": "catalog:",
13
+ "zod": "catalog:"
14
+ },
15
+ "devDependencies": {
16
+ "@m5kdev/config": "catalog:",
17
+ "tslib": "catalog:",
18
+ "typescript": "catalog:"
19
+ },
20
+ "exports": {
21
+ "./modules/app/*": "./src/modules/app/*.ts",
22
+ "./modules/posts/*": "./src/modules/posts/*.ts"
23
+ }
24
+ }
@@ -0,0 +1,5 @@
1
+ export const POST_STATUS_VALUES = ["draft", "published"] as const;
2
+
3
+ export const POST_FILTER_VALUES = ["all", ...POST_STATUS_VALUES] as const;
4
+
5
+ export const POSTS_PAGE_SIZE = 6;
@@ -0,0 +1,76 @@
1
+ import { querySchema } from "@m5kdev/commons/modules/schemas/query.schema";
2
+ import { z } from "zod";
3
+ import { POST_STATUS_VALUES } from "./posts.constants";
4
+
5
+ export const postStatusSchema = z.enum(POST_STATUS_VALUES);
6
+ export type PostStatusSchema = z.infer<typeof postStatusSchema>;
7
+
8
+ export const postSchema = z.object({
9
+ id: z.string(),
10
+ authorUserId: z.string().nullish(),
11
+ organizationId: z.string().nullish(),
12
+ teamId: z.string().nullish(),
13
+ title: z.string(),
14
+ slug: z.string(),
15
+ excerpt: z.string().nullish(),
16
+ content: z.string(),
17
+ status: postStatusSchema,
18
+ publishedAt: z.date().nullish(),
19
+ createdAt: z.date(),
20
+ updatedAt: z.date().nullish(),
21
+ deletedAt: z.date().nullish(),
22
+ });
23
+ export type PostSchema = z.infer<typeof postSchema>;
24
+
25
+ export const postsListInputSchema = querySchema.extend({
26
+ search: z.string().optional(),
27
+ status: postStatusSchema.optional(),
28
+ });
29
+ export type PostsListInputSchema = z.infer<typeof postsListInputSchema>;
30
+
31
+ export const postsListOutputSchema = z.object({
32
+ rows: z.array(postSchema),
33
+ total: z.number(),
34
+ });
35
+ export type PostsListOutputSchema = z.infer<typeof postsListOutputSchema>;
36
+
37
+ export const postCreateInputSchema = z.object({
38
+ title: z.string().min(1),
39
+ slug: z.string().optional(),
40
+ excerpt: z.string().optional(),
41
+ content: z.string().min(1),
42
+ });
43
+ export type PostCreateInputSchema = z.infer<typeof postCreateInputSchema>;
44
+
45
+ export const postCreateOutputSchema = postSchema;
46
+ export type PostCreateOutputSchema = z.infer<typeof postCreateOutputSchema>;
47
+
48
+ export const postUpdateInputSchema = z.object({
49
+ id: z.string(),
50
+ title: z.string().min(1),
51
+ slug: z.string().optional(),
52
+ excerpt: z.string().optional(),
53
+ content: z.string().min(1),
54
+ });
55
+ export type PostUpdateInputSchema = z.infer<typeof postUpdateInputSchema>;
56
+
57
+ export const postUpdateOutputSchema = postSchema;
58
+ export type PostUpdateOutputSchema = z.infer<typeof postUpdateOutputSchema>;
59
+
60
+ export const postPublishInputSchema = z.object({
61
+ id: z.string(),
62
+ });
63
+ export type PostPublishInputSchema = z.infer<typeof postPublishInputSchema>;
64
+
65
+ export const postPublishOutputSchema = postSchema;
66
+ export type PostPublishOutputSchema = z.infer<typeof postPublishOutputSchema>;
67
+
68
+ export const postSoftDeleteInputSchema = z.object({
69
+ id: z.string(),
70
+ });
71
+ export type PostSoftDeleteInputSchema = z.infer<typeof postSoftDeleteInputSchema>;
72
+
73
+ export const postSoftDeleteOutputSchema = z.object({
74
+ id: z.string(),
75
+ });
76
+ export type PostSoftDeleteOutputSchema = z.infer<typeof postSoftDeleteOutputSchema>;
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": "@m5kdev/config/tsconfig.base.json",
3
+ "rootDir": ".",
4
+ "compilerOptions": {
5
+ "esModuleInterop": true,
6
+ "importHelpers": true,
7
+ "noEmit": true,
8
+ "tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
9
+ },
10
+ "exclude": ["node_modules"],
11
+ "include": ["src/**/*.ts"]
12
+ }
@@ -0,0 +1,18 @@
1
+ # AGENTS.md
2
+
3
+ ## Frontend Stack
4
+
5
+ - React + TypeScript
6
+ - React Router v7
7
+ - HeroUI
8
+ - Tailwind CSS v4
9
+ - `nuqs` for URL state
10
+ - TanStack Query + tRPC
11
+
12
+ ## Conventions
13
+
14
+ - Keep providers centralized in `src/Providers.tsx`.
15
+ - Keep routing in `src/Router.tsx`.
16
+ - Keep feature code grouped under `src/modules/<feature>/`.
17
+ - Prefer HeroUI primitives and existing framework utilities before adding custom abstractions.
18
+ - Use `useTranslation` for local app copy and rely on the bundled `web-ui` translations for auth routes.
@@ -0,0 +1,22 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta
6
+ name="viewport"
7
+ content="width=device-width, initial-scale=1.0"
8
+ />
9
+ <meta
10
+ name="description"
11
+ content="{{APP_DESCRIPTION}}"
12
+ />
13
+ <title>{{APP_NAME}}</title>
14
+ </head>
15
+ <body>
16
+ <div id="root"></div>
17
+ <script
18
+ type="module"
19
+ src="/src/main.tsx"
20
+ ></script>
21
+ </body>
22
+ </html>
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "{{PACKAGE_SCOPE}}/webapp",
3
+ "private": true,
4
+ "version": "0.0.0",
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "vite",
8
+ "build": "vite build",
9
+ "lint": "biome check .",
10
+ "lint:fix": "biome check . --write",
11
+ "check-types": "tsc --noEmit",
12
+ "preview": "vite preview"
13
+ },
14
+ "dependencies": {
15
+ "{{PACKAGE_SCOPE}}/shared": "workspace:*",
16
+ "@heroui/react": "catalog:",
17
+ "@m5kdev/commons": "catalog:",
18
+ "@m5kdev/frontend": "catalog:",
19
+ "@m5kdev/web-ui": "catalog:",
20
+ "@tanstack/react-query": "catalog:",
21
+ "@tanstack/react-query-devtools": "catalog:",
22
+ "@trpc/client": "catalog:",
23
+ "@trpc/tanstack-react-query": "catalog:",
24
+ "better-auth": "catalog:",
25
+ "i18next": "catalog:",
26
+ "lucide-react": "catalog:",
27
+ "nuqs": "catalog:",
28
+ "posthog-js": "catalog:",
29
+ "react": "catalog:",
30
+ "react-dom": "catalog:",
31
+ "react-hook-form": "catalog:",
32
+ "react-i18next": "catalog:",
33
+ "react-router": "catalog:",
34
+ "sonner": "catalog:",
35
+ "tailwindcss": "catalog:",
36
+ "tw-animate-css": "catalog:",
37
+ "zod": "catalog:"
38
+ },
39
+ "devDependencies": {
40
+ "{{PACKAGE_SCOPE}}/server": "workspace:*",
41
+ "@m5kdev/config": "catalog:",
42
+ "@tailwindcss/vite": "catalog:",
43
+ "@types/react": "catalog:",
44
+ "@types/react-dom": "catalog:",
45
+ "@vitejs/plugin-react": "catalog:",
46
+ "babel-plugin-react-compiler": "catalog:",
47
+ "typescript": "catalog:",
48
+ "vite": "catalog:",
49
+ "vite-plugin-i18next-loader": "catalog:",
50
+ "vite-tsconfig-paths": "catalog:"
51
+ }
52
+ }
@@ -0,0 +1,13 @@
1
+ import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
2
+ import { BrowserRouter } from "react-router";
3
+ import { Providers } from "./Providers";
4
+
5
+ export function App() {
6
+ return (
7
+ <NuqsAdapter>
8
+ <BrowserRouter>
9
+ <Providers />
10
+ </BrowserRouter>
11
+ </NuqsAdapter>
12
+ );
13
+ }