@murumets-ee/create 0.1.18 → 0.2.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.
package/dist/index.mjs CHANGED
@@ -1,2960 +1,344 @@
1
- import{i as e,n as t,o as n,r,t as i}from"./utils-Bmw75lEa.mjs";import{join as a}from"node:path";import{mkdir as o}from"node:fs/promises";async function s(e,t){await n(a(e,`lib/admin-config.ts`),`import { Media } from '@murumets-ee/media'
2
- import {
3
- Ticket,
4
- TicketMessage,
5
- TicketAttachment,
6
- Department,
7
- TicketTag,
8
- } from '@murumets-ee/ticketing'
9
- import { Article, Category } from '@/entities'
10
-
11
- /** All entities available in the admin (drives sidebar nav + CRUD) */
12
- export const allEntities = [
13
- Article, Media, Category,
14
- Ticket, TicketMessage, TicketAttachment, Department, TicketTag,
15
- ]
1
+ import{a as e,n as t,r as n,t as r}from"./utils-DHetVfSn.mjs";import{existsSync as i,readFileSync as a}from"node:fs";import{mkdir as o,readFile as s,readdir as c,writeFile as l}from"node:fs/promises";import{dirname as u,join as d}from"node:path";import{fileURLToPath as f}from"node:url";import{extract as p}from"tar";const m={myorgCore:`0.1.6`,myorgDb:`0.1.5`,myorgEntity:`0.2.0`,myorgLogging:`0.1.6`,myorgAuth:`0.1.5`,myorgAuthUi:`0.1.5`,myorgAdminUi:`0.2.0`,myorgContent:`0.2.0`,myorgContentApi:`0.1.5`,myorgSettings:`0.1.6`,myorgStorage:`0.1.6`,myorgMedia:`0.2.0`,myorgTaxonomy:`0.1.5`,myorgQueue:`0.1.6`,myorgMail:`0.1.5`,myorgTicketing:`0.2.0`,myorgTicketingUi:`0.2.0`,myorgEditor:`0.1.5`,myorgBlocks:`0.1.5`,myorgTokens:`0.1.5`,myorgUi:`0.1.5`,myorgCli:`0.1.10`,betterAuth:`1.4.0`,drizzleOrm:`0.45.1`,nextIntl:`4.8.2`,nextThemes:`0.4.6`,lucideReact:`0.563.0`,postgres:`3.4.5`,reactHookForm:`7.71.1`,hookformResolvers:`5.2.2`,zod:`3.24.1`,tanstackReactQuery:`5.60.5`,tanstackReactTable:`8.21.3`,next:`16.1.6`,react:`19.2.3`,reactDom:`19.2.3`,drizzleKit:`0.31.10`,tsx:`4.19.2`,babelReactCompiler:`1.0.0`};async function h(e,t,n){await g(e,t),n?.(`Workspace root created`),n?.(`Creating admin app...`),await v(e,t),n?.(`Admin app created`),n?.(`Creating web app...`),await y(e,t),n?.(`Web app created`),await _(e,t),n?.(`Shared config package created`)}async function g(t,n){let{name:r}=n;await o(t,{recursive:!0}),await e(d(t,`pnpm-workspace.yaml`),`packages:
2
+ - 'packages/*'
3
+ - 'apps/*'
16
4
 
17
- /** Taxonomy entities keyed by name */
18
- export const taxonomyVocabularies = {
19
- category: Category,
20
- department: Department,
21
- ticket_tag: TicketTag,
22
- }
5
+ ignoredBuiltDependencies:
6
+ - sharp
7
+ - unrs-resolver
8
+ `),await e(d(t,`package.json`),`${JSON.stringify({name:`@${r}/monorepo`,version:`0.0.0`,private:!0,type:`module`,scripts:{dev:`turbo dev`,build:`turbo build`,"db:generate":`tsx --env-file=.env packages/config/scripts/generate-schema.ts`,"db:migrate:generate":`drizzle-kit generate --config packages/config/drizzle.config.ts`,"db:migrate":`tsx --env-file=.env packages/config/scripts/migrate.ts`,"db:reset":`tsx --env-file=.env packages/config/scripts/reset-db.ts`},devDependencies:{turbo:`^2.3.3`,typescript:`^5.7.3`,tsx:`^${m.tsx}`,"drizzle-kit":`^${m.drizzleKit}`}},null,2)}\n`),await e(d(t,`.gitignore`),`# dependencies
9
+ node_modules/
23
10
 
24
- /** Entities exposed via generic CRUD API handler */
25
- export const crudEntities = [Article, Media, Ticket, TicketMessage, TicketAttachment]
11
+ # build
12
+ dist/
13
+ .next/
14
+ .turbo/
26
15
 
27
- /** Plugin resources for the permission catalog */
28
- export const pluginResources = [
29
- { resource: 'storage', actions: ['view', 'create', 'update', 'delete'] },
30
- { resource: 'settings', actions: ['view', 'update'] },
31
- { resource: 'audit-logs', actions: ['view'] },
32
- { resource: 'permissions', actions: ['view', 'create', 'update', 'delete'] },
33
- { resource: 'ticketing', actions: ['view', 'create', 'update', 'delete'] },
34
- ]
35
- `),await n(a(e,`lib/content-locale.ts`),`import { cookies } from 'next/headers'
36
- import { hasLocale } from 'next-intl'
37
- import { routing } from '@/i18n/routing'
16
+ # env
17
+ .env*
18
+ !.env.example
38
19
 
39
- /**
40
- * Get the content editing locale from the cookie, falling back to the interface locale.
41
- */
42
- export async function getContentLocale(interfaceLocale: string): Promise<string> {
43
- const jar = await cookies()
44
- const raw = jar.get('content-locale')?.value
45
- if (raw && hasLocale(routing.locales, raw)) return raw
46
- return interfaceLocale
47
- }
48
- `),await n(a(e,`lib/with-admin-context.ts`),`import { getContentConfig } from '@murumets-ee/content/plugin'
49
- import type { RequestContext } from '@murumets-ee/core'
50
- import { runWithContextAsync } from '@murumets-ee/core'
51
- import { getToolkitApp } from './app'
52
- import { getContentLocale } from './content-locale'
20
+ # toolkit generated
21
+ generated/
53
22
 
54
- export async function withAdminContext<T>(
55
- interfaceLocale: string,
56
- fn: (ctx: { locale: string; defaultLocale: string }) => Promise<T>,
57
- ): Promise<T> {
58
- const app = await getToolkitApp()
59
- const contentLocale = await getContentLocale(interfaceLocale)
60
- const { defaultLocale } = getContentConfig()
23
+ # IDE
24
+ .vscode/
25
+ .idea/
61
26
 
62
- const context: RequestContext = {
63
- locale: contentLocale,
64
- defaultLocale,
65
- app,
66
- }
27
+ # OS
28
+ .DS_Store
29
+ Thumbs.db
30
+
31
+ # logs
32
+ *.log
33
+
34
+ # migrations meta
35
+ migrations/
36
+
37
+ # turbo
38
+ .turbo/
39
+ `),await e(d(t,`.env.example`),`DATABASE_URL=postgresql://${r}:${r}_dev_password@localhost:5432/${r}_dev
40
+ BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
41
+ BETTER_AUTH_URL=http://localhost:3000
42
+ LOG_LEVEL=debug
43
+ NEXT_PUBLIC_APP_URL=http://localhost:3000
44
+ QUEUE_WORKER=true
45
+ RESEND_API_KEY=
46
+ RESEND_WEBHOOK_SECRET=
47
+ MAIL_FROM=noreply@example.com
48
+ CSAT_SECRET=
49
+ `),await e(d(t,`docker-compose.yml`),`services:
50
+ postgres:
51
+ image: postgres:17-alpine
52
+ container_name: ${r}-postgres
53
+ restart: unless-stopped
54
+ environment:
55
+ POSTGRES_USER: ${r}
56
+ POSTGRES_PASSWORD: ${r}_dev_password
57
+ POSTGRES_DB: ${r}_dev
58
+ ports:
59
+ - "5432:5432"
60
+ volumes:
61
+ - postgres_data:/var/lib/postgresql/data
62
+ healthcheck:
63
+ test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}_dev"]
64
+ interval: 10s
65
+ timeout: 5s
66
+ retries: 5
67
+
68
+ volumes:
69
+ postgres_data:
70
+ name: ${r}_postgres_data
71
+ `)}async function _(t,n){let{name:r}=n,i=d(t,`packages/config`);await e(d(i,`package.json`),`${JSON.stringify({name:`@${r}/config`,version:`0.1.0`,private:!0,type:`module`,exports:{".":`./toolkit.config.ts`,"./entities":`./entities/index.ts`,"./entities/*":`./entities/*`,"./app":`./lib/app.ts`,"./auth":`./lib/auth.ts`,"./auth-client":`./lib/auth-client.ts`},dependencies:{"@murumets-ee/core":`^${m.myorgCore}`,"@murumets-ee/db":`^${m.myorgDb}`,"@murumets-ee/entity":`^${m.myorgEntity}`,"@murumets-ee/logging":`^${m.myorgLogging}`,"@murumets-ee/auth":`^${m.myorgAuth}`,"better-auth":`^${m.betterAuth}`,"drizzle-orm":`^${m.drizzleOrm}`,postgres:`^${m.postgres}`,zod:`^${m.zod}`}},null,2)}\n`),await e(d(i,`tsconfig.json`),`${JSON.stringify({compilerOptions:{target:`ES2022`,module:`ESNext`,moduleResolution:`Bundler`,strict:!0,esModuleInterop:!0,skipLibCheck:!0,resolveJsonModule:!0},include:[`**/*.ts`]},null,2)}\n`),await e(d(i,`toolkit.config.ts`),`import { auth } from '@murumets-ee/auth/plugin'
72
+ import { content } from '@murumets-ee/content/plugin'
73
+ import { defineConfig } from '@murumets-ee/core'
74
+ import { logging } from '@murumets-ee/logging/plugin'
75
+ import { mail, ResendMailProvider } from '@murumets-ee/mail'
76
+ import { media } from '@murumets-ee/media/plugin'
77
+ import { queue } from '@murumets-ee/queue/plugin'
78
+ import { settings } from '@murumets-ee/settings/plugin'
79
+ import { storage } from '@murumets-ee/storage/plugin'
80
+ import { taxonomy } from '@murumets-ee/taxonomy/plugin'
81
+ import { ticketing } from '@murumets-ee/ticketing/plugin'
82
+ import { Article, Category } from './entities'
83
+ import * as authSchema from './generated/auth-schema'
67
84
 
68
- return runWithContextAsync(context, () => fn({ locale: contentLocale, defaultLocale }))
85
+ if (!process.env.DATABASE_URL) {
86
+ throw new Error('DATABASE_URL environment variable is required')
69
87
  }
70
- `),await n(a(e,`lib/load-roles.ts`),`import { buildInitialRoleDefinitions } from '@murumets-ee/auth'
71
- import type { ToolkitApp } from '@murumets-ee/core'
72
- import { createSettingsClient } from '@murumets-ee/settings'
73
- import { permissionSettings } from '@/settings/permissions'
74
-
75
- export async function loadRoles(
76
- app: ToolkitApp,
77
- ): Promise<Record<string, Record<string, string[]>>> {
78
- const client = createSettingsClient(permissionSettings, { app })
79
- const saved = await client.get('roles')
80
-
81
- if (saved) return saved
82
-
83
- // First run — seed built-in roles with zero permissions
84
- const initial = buildInitialRoleDefinitions()
85
- await client.set('roles', initial)
86
- return initial
88
+
89
+ export default defineConfig({
90
+ db: {
91
+ url: process.env.DATABASE_URL,
92
+ poolMin: 2,
93
+ poolMax: 10,
94
+ },
95
+ logging: {
96
+ level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
97
+ name: '${r}',
98
+ },
99
+ entities: [Category, Article],
100
+ plugins: [
101
+ auth({ providers: ['email'], schema: authSchema }),
102
+ content({
103
+ locales: [{ code: 'en', label: 'English' }],
104
+ defaultLocale: 'en',
105
+ }),
106
+ logging(),
107
+ settings(),
108
+ storage(),
109
+ media(),
110
+ taxonomy(),
111
+ queue(),
112
+ mail({
113
+ provider: process.env.RESEND_API_KEY
114
+ ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
115
+ : undefined,
116
+ defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
117
+ webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
118
+ }),
119
+ ticketing({
120
+ csatSecret: process.env.CSAT_SECRET,
121
+ }),
122
+ ],
123
+ projectRoot: import.meta.dirname,
124
+ })
125
+ `),await e(d(i,`auth.config.ts`),`import { betterAuth } from 'better-auth'
126
+ import { drizzleAdapter } from 'better-auth/adapters/drizzle'
127
+ import { admin } from 'better-auth/plugins'
128
+ import { organization } from 'better-auth/plugins/organization'
129
+ import { drizzle } from 'drizzle-orm/postgres-js'
130
+ import postgres from 'postgres'
131
+
132
+ const sql = postgres(process.env.DATABASE_URL!)
133
+ const db = drizzle(sql)
134
+
135
+ export const auth = betterAuth({
136
+ database: drizzleAdapter(db, { provider: 'pg' }),
137
+ emailAndPassword: { enabled: true },
138
+ plugins: [
139
+ admin(),
140
+ organization(),
141
+ ],
142
+ })
143
+ `),await e(d(i,`drizzle.config.ts`),`import type { Config } from 'drizzle-kit'
144
+
145
+ if (!process.env.DATABASE_URL) {
146
+ throw new Error('DATABASE_URL environment variable is required')
87
147
  }
88
- `),await n(a(e,`settings/permissions.ts`),`import { defineSettings, setting } from '@murumets-ee/settings'
89
148
 
90
- export const permissionSettings = defineSettings({
91
- namespace: 'permissions',
149
+ export default {
150
+ schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
151
+ out: './migrations',
152
+ dialect: 'postgresql',
153
+ dbCredentials: {
154
+ url: process.env.DATABASE_URL,
155
+ },
156
+ } satisfies Config
157
+ `),await e(d(i,`entities/index.ts`),`export { Article } from './article'
158
+ export { Category } from './category'
159
+ `),await e(d(i,`entities/article.ts`),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
160
+
161
+ export const Article = defineEntity({
162
+ name: 'article',
163
+ fields: {
164
+ title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
165
+ slug: field.slug({ from: 'title', unique: true }),
166
+ excerpt: field.text({ maxLength: 500, translatable: true }),
167
+ body: field.richtext(),
168
+ viewCount: field.number({ default: 0, integer: true }),
169
+ featured: field.boolean({ default: false }),
170
+ publishDate: field.date(),
171
+ contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
172
+ category: field.reference({ entity: 'category', required: false }),
173
+ tags: field.reference({ entity: 'category', cardinality: 'many' }),
174
+ coverImage: field.media({ accept: ['image/*'] }),
175
+ },
176
+ behaviors: [
177
+ behavior.publishable(),
178
+ behavior.auditable(),
179
+ behavior.sluggable('title'),
180
+ behavior.revisionable(),
181
+ ],
92
182
  scope: 'global',
93
- label: 'Permissions',
94
- schema: {
95
- roles: setting.json<Record<string, Record<string, string[]>>>(),
183
+ access: {
184
+ view: 'public',
185
+ create: 'group.editor',
186
+ update: 'group.editor',
187
+ delete: 'group.admin',
96
188
  },
97
189
  })
98
- `),await n(a(e,`settings/site.ts`),`import { defineSettings, setting } from '@murumets-ee/settings'
190
+ `),await e(d(i,`entities/category.ts`),`import { defineEntity, field } from '@murumets-ee/entity'
99
191
 
100
- export const siteSettings = defineSettings({
101
- namespace: 'site',
192
+ export const Category = defineEntity({
193
+ name: 'category',
194
+ fields: {
195
+ name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
196
+ slug: field.slug({ from: 'name', unique: true }),
197
+ description: field.text({ maxLength: 500, translatable: true }),
198
+ },
102
199
  scope: 'global',
103
- label: 'Site Settings',
104
- schema: {
105
- siteName: setting.text({ default: 'My Site', label: 'Site Name', translatable: true }),
106
- siteDescription: setting.text({ label: 'Site Description', translatable: true }),
107
- maintenanceMode: setting.boolean({ default: false, label: 'Maintenance Mode' }),
108
- postsPerPage: setting.number({
109
- default: 10,
110
- min: 1,
111
- max: 100,
112
- integer: true,
113
- label: 'Posts Per Page',
114
- }),
200
+ access: {
201
+ view: 'public',
202
+ create: 'group.editor',
203
+ update: 'group.editor',
204
+ delete: 'group.admin',
115
205
  },
116
206
  })
117
- `),await n(a(e,`app/admin-layout.tsx`),`'use client'
118
-
119
- import type { LinkComponent, SidebarNavGroup } from '@murumets-ee/admin-ui'
120
- import { AdminShell } from '@murumets-ee/admin-ui'
121
- import type { AdminNavGroup } from '@murumets-ee/admin-ui/server'
122
- import {
123
- Activity,
124
- Building2,
125
- FileText,
126
- FolderTree,
127
- Home,
128
- Image,
129
- ImageDown,
130
- Inbox,
131
- Kanban,
132
- Lock,
133
- PenTool,
134
- ShieldCheck,
135
- Tag,
136
- Tags,
137
- Ticket,
138
- Users,
139
- type LucideIcon,
140
- } from 'lucide-react'
141
- import type { ReactNode } from 'react'
142
- import { Link, usePathname } from '@/i18n/navigation'
143
-
144
- /** Map from icon name strings (from entity admin config) to Lucide components */
145
- const ICON_MAP: Record<string, LucideIcon> = {
146
- 'file-text': FileText,
147
- 'folder-tree': FolderTree,
148
- tags: Tags,
149
- ticket: Ticket,
150
- }
207
+ `),await e(d(i,`lib/app.ts`),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
208
+ import config from '../toolkit.config'
209
+
210
+ let appInstance: ToolkitApp | null = null
151
211
 
152
- interface AdminLayoutProps {
153
- children: ReactNode
154
- defaultOpen?: boolean
155
- headerActions?: ReactNode
156
- sidebarFooter?: ReactNode
157
- entityNavGroups?: AdminNavGroup[]
212
+ export async function getToolkitApp(): Promise<ToolkitApp> {
213
+ if (!appInstance) {
214
+ appInstance = await createApp(config)
215
+ setApp(appInstance)
216
+ }
217
+ return appInstance
158
218
  }
219
+ `),await e(d(i,`lib/auth.ts`),`import { getAuth } from '@murumets-ee/auth'
220
+ import { getToolkitApp } from './app'
159
221
 
160
- export function AdminLayout({
161
- children,
162
- defaultOpen,
163
- headerActions,
164
- sidebarFooter,
165
- entityNavGroups,
166
- }: AdminLayoutProps) {
167
- const pathname = usePathname()
222
+ await getToolkitApp()
168
223
 
169
- const dynamicGroups: SidebarNavGroup[] = (entityNavGroups ?? []).map((group) => ({
170
- label: group.label,
171
- items: group.items.map((item) => ({
172
- label: item.label,
173
- href: item.href,
174
- icon: item.iconName ? ICON_MAP[item.iconName] : undefined,
175
- })),
176
- }))
177
-
178
- const staticBefore: SidebarNavGroup = {
179
- items: [
180
- { label: 'Home', href: '/', icon: Home },
181
- { label: 'Admin', href: '/admin', icon: PenTool },
182
- ],
183
- }
224
+ export const auth = getAuth()
225
+ `),await e(d(i,`lib/auth-client.ts`),`import { createClient } from '@murumets-ee/auth/client'
184
226
 
185
- const supportGroup: SidebarNavGroup = {
186
- label: 'Support',
187
- items: [
188
- { label: 'Tickets', href: '/admin/tickets', icon: Inbox },
189
- { label: 'Board', href: '/admin/tickets/board', icon: Kanban },
190
- { label: 'Departments', href: '/admin/departments', icon: Building2 },
191
- { label: 'Ticket Tags', href: '/admin/ticket-tags', icon: Tag },
192
- ],
193
- }
227
+ export const authClient = createClient()
228
+ `),await e(d(i,`generated/auth-schema.ts`),`// This file is generated by better-auth CLI.
229
+ // Run: npx @better-auth/cli generate --config auth.config.ts -y
230
+ export {}
231
+ `),await e(d(i,`scripts/generate-schema.ts`),`/**
232
+ * Generate Drizzle schemas from entity definitions
233
+ */
194
234
 
195
- const systemGroup: SidebarNavGroup = {
196
- label: 'System',
197
- items: [
198
- { label: 'Users', href: '/admin/users', icon: Users },
199
- { label: 'Roles', href: '/admin/roles', icon: ShieldCheck },
200
- { label: 'Permissions', href: '/admin/permissions', icon: Lock },
201
- { label: 'Media', href: '/admin/media', icon: Image },
202
- { label: 'Image Styles', href: '/admin/media/image-styles', icon: ImageDown },
203
- { label: 'Activity', href: '/admin/activity', icon: Activity },
204
- ],
205
- }
235
+ import { mkdir, writeFile } from 'node:fs/promises'
236
+ import { join } from 'node:path'
237
+ import { generateSchemaCode, generateTranslationSchemaCode } from '@murumets-ee/entity'
238
+ import config from '../toolkit.config'
206
239
 
207
- const navGroups: SidebarNavGroup[] = [
208
- staticBefore,
209
- ...dynamicGroups,
210
- supportGroup,
211
- systemGroup,
212
- ]
240
+ async function generateSchemas() {
241
+ console.log('Generating Drizzle schemas from entity definitions...')
213
242
 
214
- return (
215
- <AdminShell
216
- defaultOpen={defaultOpen}
217
- sidebar={{
218
- navGroups,
219
- pathname,
220
- Link: Link as unknown as LinkComponent,
221
- logo: (
222
- <div className="flex h-8 items-center gap-2 px-2">
223
- <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 truncate">
224
- Admin
225
- </span>
226
- </div>
227
- ),
228
- footer: sidebarFooter,
229
- }}
230
- header={{
231
- actions: headerActions,
232
- }}
233
- >
234
- {children}
235
- </AdminShell>
236
- )
237
- }
238
- `),await n(a(e,`app/[locale]/(shell)/layout.tsx`),`import { buildAdminNav } from '@murumets-ee/admin-ui/server'
239
- import { cookies, headers } from 'next/headers'
240
- import { redirect } from 'next/navigation'
241
- import { Suspense } from 'react'
242
- import { allEntities } from '@/lib/admin-config'
243
- import { auth } from '@/lib/auth'
244
- import { AdminLayout } from '../../admin-layout'
245
- import { ThemeToggle } from '../../theme-toggle'
246
-
247
- async function DynamicShell({
248
- children,
249
- interfaceLocale,
250
- }: {
251
- children: React.ReactNode
252
- interfaceLocale: string
253
- }) {
254
- const h = await headers()
255
- const session = await auth.api.getSession({ headers: h })
256
- if (!session?.user) {
257
- redirect(\`/\${interfaceLocale}/auth/sign-in\`)
258
- }
243
+ const schemaDir = join(import.meta.dirname, '..', 'generated')
244
+ await mkdir(schemaDir, { recursive: true })
259
245
 
260
- const jar = await cookies()
261
- const sidebarOpen = jar.get('sidebar:state')?.value !== 'false'
262
- const entityNavGroups = buildAdminNav(allEntities, { basePath: '/admin' })
246
+ const imports: string[] = []
247
+ const schemas: string[] = []
248
+ let hasTranslations = false
263
249
 
264
- return (
265
- <AdminLayout
266
- defaultOpen={sidebarOpen}
267
- entityNavGroups={entityNavGroups}
268
- sidebarFooter={
269
- <div className="flex justify-center gap-2">
270
- <ThemeToggle />
271
- </div>
272
- }
273
- >
274
- {children}
275
- </AdminLayout>
276
- )
277
- }
250
+ for (const entity of config.entities) {
251
+ console.log(\` - Generating schema for \${entity.name}\`)
278
252
 
279
- export default async function ShellLayout({
280
- children,
281
- params,
282
- }: {
283
- children: React.ReactNode
284
- params: Promise<{ locale: string }>
285
- }) {
286
- const { locale } = await params
253
+ const schemaCode = generateSchemaCode(entity)
254
+ schemas.push(\`\\n// \${entity.name} table\`)
255
+ schemas.push(schemaCode)
287
256
 
288
- return (
289
- <Suspense>
290
- <DynamicShell interfaceLocale={locale}>{children}</DynamicShell>
291
- </Suspense>
292
- )
293
- }
294
- `),await n(a(e,`app/[locale]/(shell)/admin/layout.tsx`),`import { headers } from 'next/headers'
295
- import { redirect } from 'next/navigation'
296
- import { auth } from '@/lib/auth'
297
-
298
- const ALLOWED_ROLES = new Set(['admin', 'editor'])
299
-
300
- export default async function AdminGuardLayout({
301
- children,
302
- params,
303
- }: {
304
- children: React.ReactNode
305
- params: Promise<{ locale: string }>
306
- }) {
307
- const { locale } = await params
308
- const h = await headers()
309
- const session = await auth.api.getSession({ headers: h })
310
-
311
- if (!session?.user) {
312
- redirect(\`/\${locale}/auth/sign-in\`)
313
- }
314
-
315
- const role = (session.user as Record<string, unknown>).role as string | undefined
316
- if (!role || !ALLOWED_ROLES.has(role)) {
317
- redirect(\`/\${locale}\`)
318
- }
319
-
320
- return <>{children}</>
321
- }
322
- `),await n(a(e,`app/[locale]/(shell)/admin/page.tsx`),`import { setRequestLocale } from 'next-intl/server'
323
-
324
- export default async function AdminDashboard({ params }: { params: Promise<{ locale: string }> }) {
325
- const { locale } = await params
326
- setRequestLocale(locale)
327
-
328
- return (
329
- <div className="p-6">
330
- <h1 className="text-2xl font-bold mb-4">Dashboard</h1>
331
- <p className="text-muted-foreground">
332
- Welcome to the admin panel. Use the sidebar to manage your content.
333
- </p>
334
- </div>
335
- )
336
- }
337
- `),await n(a(e,`app/[locale]/(shell)/admin/[entity]/page.tsx`),`import { EntityList } from '@murumets-ee/admin-ui/entity-list'
338
- import {
339
- entityNameToSlug,
340
- fetchEntityList,
341
- getEntityLabel,
342
- resolveEntityFromSlug,
343
- toEntityMeta,
344
- } from '@murumets-ee/admin-ui/server'
345
- import { getContentConfig } from '@murumets-ee/content/plugin'
346
- import { notFound } from 'next/navigation'
347
- import { setRequestLocale } from 'next-intl/server'
348
- import { allEntities, taxonomyVocabularies } from '@/lib/admin-config'
349
- import { withAdminContext } from '@/lib/with-admin-context'
350
-
351
- interface EntityListPageProps {
352
- params: Promise<{ locale: string; entity: string }>
353
- }
354
-
355
- export default async function EntityListPage({ params }: EntityListPageProps) {
356
- const { locale, entity: entitySlug } = await params
357
- setRequestLocale(locale)
358
-
359
- const entity = resolveEntityFromSlug(entitySlug, allEntities)
360
- if (!entity) notFound()
361
-
362
- const admin = entity.admin
363
- const meta = toEntityMeta(entity)
364
- const urlSlug = entityNameToSlug(entity.name)
365
- const hasTranslatable = Object.values(entity.allFields).some((f) => f.translatable)
366
- const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable')
367
- const isTaxonomy = entity.name in taxonomyVocabularies
368
-
369
- return withAdminContext(locale, async ({ locale: contentLocale, defaultLocale }) => {
370
- let locales: Array<{ code: string; label: string }> | undefined
371
- if (hasTranslatable) {
372
- try {
373
- const config = getContentConfig()
374
- locales = config.locales
375
- } catch {
376
- // content plugin not available
377
- }
378
- }
379
-
380
- const initialData = await fetchEntityList(entity, {
381
- sortField: admin?.defaultSort ?? 'createdAt',
382
- sortDirection: admin?.defaultSortDirection ?? 'desc',
383
- limit: admin?.pageSize ?? 20,
384
- locale: contentLocale,
385
- includeTranslationStatus: hasTranslatable,
386
- })
387
-
388
- return (
389
- <div className="p-6">
390
- <div className="mb-6">
391
- <h1 className="text-2xl font-bold">{getEntityLabel(entity)}</h1>
392
- {admin?.description && (
393
- <p className="text-sm text-muted-foreground">{admin.description}</p>
394
- )}
395
- </div>
396
- <EntityList
397
- entity={meta}
398
- allowDelete
399
- allowStatusToggle={isPublishable}
400
- {...(isTaxonomy ? { apiBasePath: '/api/admin/taxonomy', entityPath: entity.name } : {})}
401
- defaultSort={admin?.defaultSort ?? 'createdAt'}
402
- defaultSortDirection={admin?.defaultSortDirection ?? 'desc'}
403
- hiddenColumns={admin?.hiddenColumns}
404
- pageSize={admin?.pageSize}
405
- locale={contentLocale}
406
- locales={locales}
407
- defaultLocale={defaultLocale}
408
- showTranslationStatus={hasTranslatable && !!locales}
409
- searchPlaceholder={\`Search \${getEntityLabel(entity).toLowerCase()}...\`}
410
- editHref={\`/\${locale}/admin/\${urlSlug}/:id\`}
411
- createHref={admin?.disableCreate ? undefined : \`/\${locale}/admin/\${urlSlug}/new\`}
412
- initialData={initialData}
413
- />
414
- </div>
415
- )
416
- })
417
- }
418
- `),await n(a(e,`app/[locale]/(shell)/admin/[entity]/[id]/page.tsx`),`import {
419
- entityNameToSlug,
420
- fetchReferenceOptions,
421
- getBlocksFieldName,
422
- getEntityLabelSingular,
423
- getReferenceFields,
424
- inferRootFields,
425
- resolveEntityFromSlug,
426
- toEntityMeta,
427
- } from '@murumets-ee/admin-ui/server'
428
- import { buildPermissionChecker } from '@murumets-ee/auth'
429
- import { ContentClient } from '@murumets-ee/content/client'
430
- import { LockService } from '@murumets-ee/content/lock'
431
- import { createAdminClient } from '@murumets-ee/core/clients'
432
- import { getApp } from '@murumets-ee/core'
433
- import { prepareBlockEditor } from '@murumets-ee/editor/server'
434
- import { headers } from 'next/headers'
435
- import { notFound } from 'next/navigation'
436
- import { setRequestLocale } from 'next-intl/server'
437
- import { GenericBlockEditor } from '@murumets-ee/admin-ui/content-editor'
438
- import { GenericEntityForm } from '@murumets-ee/admin-ui/entity-form'
439
- import { allEntities } from '@/lib/admin-config'
440
- import { auth } from '@/lib/auth'
441
- import { loadRoles } from '@/lib/load-roles'
442
- import { withAdminContext } from '@/lib/with-admin-context'
443
-
444
- interface EditEntityPageProps {
445
- params: Promise<{ locale: string; entity: string; id: string }>
446
- }
447
-
448
- export default async function EditEntityPage({ params }: EditEntityPageProps) {
449
- const { locale, entity: entitySlug, id } = await params
450
- setRequestLocale(locale)
451
-
452
- const entity = resolveEntityFromSlug(entitySlug, allEntities)
453
- if (!entity) notFound()
454
-
455
- const blocksField = getBlocksFieldName(entity)
456
- const urlSlug = entityNameToSlug(entity.name)
457
-
458
- return withAdminContext(locale, async ({ locale: contentLocale, defaultLocale }) => {
459
- const client = createAdminClient(entity)
460
- const isDefaultLocale = contentLocale === defaultLocale
461
- const isNonDefaultLocale = !isDefaultLocale
462
-
463
- const isVersionable = entity.behaviors?.some((b) => b.name === 'versionable') ?? false
464
- const isPublishable = entity.behaviors?.some((b) => b.name === 'publishable') ?? false
465
-
466
- let draft: { data: Record<string, unknown>; createdBy: string; createdByName: string | null; updatedAt: Date | string } | undefined
467
- let canPublish = false
468
- let lockResult: { acquired: boolean; lock?: { lockedBy: string; lockedByName: string | null; lockedAt: Date | string; expiresAt: Date | string } | null } | undefined
469
- const enableLocking = isPublishable
470
-
471
- if (isPublishable) {
472
- try {
473
- const app = getApp()
474
- const contentClient = new ContentClient({ entity, db: app.db.readWrite })
475
- const lockLocale = isDefaultLocale ? '_' : contentLocale
476
-
477
- const draftEntry = await contentClient.getDraft(id, lockLocale)
478
- if (draftEntry) {
479
- draft = {
480
- data: draftEntry.data,
481
- createdBy: draftEntry.createdBy,
482
- createdByName: draftEntry.createdByName,
483
- updatedAt: draftEntry.updatedAt,
484
- }
485
- }
486
-
487
- const session = await auth.api.getSession({ headers: await headers() })
488
- if (session?.user) {
489
- const role = (session.user as Record<string, unknown>).role as string | undefined
490
- if (role) {
491
- const roles = await loadRoles(app)
492
- const checker = buildPermissionChecker(roles)
493
- canPublish = checker(role, entity.name, 'publish')
494
- }
495
-
496
- const lockService = new LockService({ db: app.db.readWrite })
497
- const result = await lockService.acquireLock(
498
- entity.name, id, lockLocale,
499
- { id: session.user.id, name: session.user.name ?? undefined },
500
- )
501
- if (result.acquired) {
502
- lockResult = { acquired: true }
503
- } else {
504
- lockResult = { acquired: false, lock: result.lock }
505
- }
506
- }
507
- } catch {
508
- // Draft/permission/lock errors — proceed without
509
- }
510
- }
511
-
512
- // Block editor entity
513
- if (blocksField) {
514
- const record = isDefaultLocale
515
- ? await client.findById(id, { defaultLocale })
516
- : await client.findById(id, { locale: contentLocale, defaultLocale })
517
- if (!record) notFound()
518
-
519
- const rootFields = inferRootFields(entity, blocksField)
520
- const { blocks, initialData, mediaUrls, rootData, rootFieldDefs } =
521
- await prepareBlockEditor(
522
- entity as Parameters<typeof prepareBlockEditor>[0],
523
- blocksField,
524
- record,
525
- { rootFields },
526
- )
527
-
528
- return (
529
- <div className="flex h-[calc(100vh-3.5rem)] flex-col">
530
- <div className="flex items-center gap-3 border-b px-6 py-3">
531
- <h1 className="text-lg font-semibold">
532
- {(record.title as string) ?? \`Untitled \${getEntityLabelSingular(entity)}\`}
533
- </h1>
534
- <span className="rounded bg-muted px-2 py-0.5 text-xs text-muted-foreground uppercase">
535
- {contentLocale}
536
- </span>
537
- </div>
538
- <div className="flex-1 overflow-hidden">
539
- <GenericBlockEditor
540
- key={contentLocale}
541
- entityId={id}
542
- entityName={entity.name}
543
- blocksField={blocksField}
544
- blocks={blocks}
545
- initialData={initialData}
546
- mediaUrls={mediaUrls}
547
- rootData={rootData}
548
- rootFieldDefs={rootFieldDefs}
549
- saveLabel="Save"
550
- locale={contentLocale}
551
- defaultLocale={defaultLocale}
552
- versionable={isVersionable}
553
- isPublishable={isPublishable}
554
- draft={draft}
555
- canPublish={canPublish}
556
- enableLocking={enableLocking}
557
- lockResult={lockResult}
558
- />
559
- </div>
560
- </div>
561
- )
562
- }
563
-
564
- // Form entity
565
- const [record, defaultLocaleData] = await Promise.all([
566
- client.findById(id, { locale: contentLocale }),
567
- isNonDefaultLocale ? client.findById(id) : Promise.resolve(null),
568
- ])
569
- if (!record) notFound()
570
-
571
- const refFields = getReferenceFields(entity)
572
- const referenceOptions: Record<string, Array<{ id: string; label: string }>> = {}
573
-
574
- if (refFields.length > 0) {
575
- const optionPromises = refFields.map(async ({ fieldName, entityName }) => {
576
- const refEntity = allEntities.find((e) => e.name === entityName)
577
- if (!refEntity) return { fieldName, options: [] }
578
- const options = await fetchReferenceOptions(refEntity, { locale: contentLocale })
579
- return { fieldName, options }
580
- })
581
- const results = await Promise.all(optionPromises)
582
- for (const { fieldName, options } of results) {
583
- referenceOptions[fieldName] = options
584
- }
585
- }
586
-
587
- return (
588
- <div className="p-6">
589
- <div className="mb-6">
590
- <h1 className="text-2xl font-bold">Edit {getEntityLabelSingular(entity)}</h1>
591
- </div>
592
- <GenericEntityForm
593
- entity={toEntityMeta(entity)}
594
- id={id}
595
- locale={contentLocale}
596
- defaultLocale={defaultLocale}
597
- defaultLocaleData={
598
- isNonDefaultLocale && defaultLocaleData
599
- ? (defaultLocaleData as Record<string, unknown>)
600
- : undefined
601
- }
602
- initialData={record as Record<string, unknown>}
603
- referenceOptions={referenceOptions}
604
- listUrl={\`/\${locale}/admin/\${urlSlug}\`}
605
- versionable={isVersionable}
606
- draft={draft}
607
- canPublish={canPublish}
608
- enableLocking={enableLocking}
609
- lockResult={lockResult}
610
- />
611
- </div>
612
- )
613
- })
614
- }
615
- `),await n(a(e,`app/[locale]/(shell)/admin/[entity]/new/page.tsx`),`import {
616
- entityNameToSlug,
617
- fetchReferenceOptions,
618
- getBlocksFieldName,
619
- getEntityLabelSingular,
620
- getReferenceFields,
621
- resolveEntityFromSlug,
622
- toEntityMeta,
623
- } from '@murumets-ee/admin-ui/server'
624
- import { createAdminClient } from '@murumets-ee/core/clients'
625
- import { notFound, redirect } from 'next/navigation'
626
- import { setRequestLocale } from 'next-intl/server'
627
- import { GenericEntityForm } from '@murumets-ee/admin-ui/entity-form'
628
- import { allEntities } from '@/lib/admin-config'
629
- import { withAdminContext } from '@/lib/with-admin-context'
630
-
631
- interface NewEntityPageProps {
632
- params: Promise<{ locale: string; entity: string }>
633
- }
634
-
635
- export default async function NewEntityPage({ params }: NewEntityPageProps) {
636
- const { locale, entity: entitySlug } = await params
637
- setRequestLocale(locale)
638
-
639
- const entity = resolveEntityFromSlug(entitySlug, allEntities)
640
- if (!entity) notFound()
641
- if (entity.admin?.disableCreate) notFound()
642
-
643
- const blocksField = getBlocksFieldName(entity)
644
- const urlSlug = entityNameToSlug(entity.name)
645
-
646
- return withAdminContext(locale, async ({ locale: contentLocale }) => {
647
- // Block editor entities: create a draft and redirect to the edit page
648
- if (blocksField) {
649
- const client = createAdminClient(entity)
650
- const draft = await client.create({ title: 'Untitled' })
651
- const newId = (draft as Record<string, unknown>).id as string
652
- redirect(\`/\${locale}/admin/\${urlSlug}/\${newId}\`)
653
- }
654
-
655
- // Form entities: render the create form
656
- const refFields = getReferenceFields(entity)
657
- const referenceOptions: Record<string, Array<{ id: string; label: string }>> = {}
658
-
659
- if (refFields.length > 0) {
660
- const optionPromises = refFields.map(async ({ fieldName, entityName }) => {
661
- const refEntity = allEntities.find((e) => e.name === entityName)
662
- if (!refEntity) return { fieldName, options: [] }
663
- const options = await fetchReferenceOptions(refEntity, { locale: contentLocale })
664
- return { fieldName, options }
665
- })
666
- const results = await Promise.all(optionPromises)
667
- for (const { fieldName, options } of results) {
668
- referenceOptions[fieldName] = options
669
- }
257
+ const translationCode = generateTranslationSchemaCode(entity)
258
+ if (translationCode) {
259
+ hasTranslations = true
260
+ schemas.push(\`\\n// \${entity.name} translations\`)
261
+ schemas.push(translationCode)
670
262
  }
671
-
672
- return (
673
- <div className="p-6">
674
- <div className="mb-6">
675
- <h1 className="text-2xl font-bold">New {getEntityLabelSingular(entity)}</h1>
676
- </div>
677
- <GenericEntityForm
678
- entity={toEntityMeta(entity)}
679
- locale={contentLocale}
680
- referenceOptions={referenceOptions}
681
- listUrl={\`/\${locale}/admin/\${urlSlug}\`}
682
- />
683
- </div>
684
- )
685
- })
686
- }
687
- `),await n(a(e,`app/[locale]/(shell)/admin/media/page.tsx`),`import { EntityList } from '@murumets-ee/admin-ui/entity-list'
688
- import { fetchEntityList, toEntityMeta } from '@murumets-ee/admin-ui/server'
689
- import { Media } from '@murumets-ee/media'
690
- import { getMediaClient } from '@murumets-ee/media/client'
691
- import { setRequestLocale } from 'next-intl/server'
692
-
693
- export default async function MediaListPage({ params }: { params: Promise<{ locale: string }> }) {
694
- const { locale } = await params
695
- setRequestLocale(locale)
696
- const initialData = await fetchEntityList(Media, { sortField: 'createdAt' })
697
-
698
- const mediaClient = await getMediaClient()
699
- const ids = initialData.items.map((i) => i.id as string)
700
- const thumbMap = await mediaClient.getVariantUrls(ids, 'thumbnail')
701
- for (const item of initialData.items) {
702
- item.thumbnailUrl = thumbMap.get(item.id as string) ?? ''
703
- }
704
-
705
- return (
706
- <div className="p-6">
707
- <div className="mb-6">
708
- <h1 className="text-2xl font-bold">Media</h1>
709
- <p className="text-sm text-muted-foreground">
710
- Manage uploaded media files.
711
- </p>
712
- </div>
713
- <EntityList
714
- entity={toEntityMeta(Media)}
715
- entityPath="media"
716
- image={{ field: 'thumbnailUrl', size: 'sm', shape: 'rounded' }}
717
- allowDelete
718
- defaultSort="createdAt"
719
- searchPlaceholder="Search media..."
720
- editHref={\`/\${locale}/admin/media/:id\`}
721
- initialData={initialData}
722
- />
723
- </div>
724
- )
725
- }
726
- `),await n(a(e,`app/[locale]/(shell)/admin/media/[id]/page.tsx`),`import { toEntityMeta } from '@murumets-ee/admin-ui/server'
727
- import { getCurrentApp } from '@murumets-ee/core'
728
- import { createAdminClient } from '@murumets-ee/core/clients'
729
- import { Media } from '@murumets-ee/media'
730
- import { findMediaUsages } from '@murumets-ee/media/usage'
731
- import { notFound } from 'next/navigation'
732
- import { setRequestLocale } from 'next-intl/server'
733
- import { withAdminContext } from '@/lib/with-admin-context'
734
- import { MediaForm } from './media-form'
735
- import { MediaUsagePanel } from './media-usage-panel'
736
-
737
- interface MediaEditPageProps {
738
- params: Promise<{ locale: string; id: string }>
739
- }
740
-
741
- export default async function MediaEditPage({ params }: MediaEditPageProps) {
742
- const { locale, id } = await params
743
- setRequestLocale(locale)
744
-
745
- return withAdminContext(locale, async ({ locale: contentLocale }) => {
746
- const app = getCurrentApp()!
747
- const client = createAdminClient(Media)
748
- const [mediaItem, usages] = await Promise.all([
749
- client.findById(id, { locale: contentLocale }),
750
- findMediaUsages(id, app.db.readWrite),
751
- ])
752
- if (!mediaItem) notFound()
753
-
754
- return (
755
- <div className="p-6">
756
- <div className="mb-6">
757
- <h1 className="text-2xl font-bold">Edit Media</h1>
758
- </div>
759
- <div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
760
- <div className="lg:col-span-2">
761
- <MediaForm
762
- entity={toEntityMeta(Media)}
763
- id={id}
764
- locale={contentLocale}
765
- initialData={mediaItem as Record<string, unknown>}
766
- />
767
- </div>
768
- <div>
769
- <MediaUsagePanel usages={usages} locale={locale} />
770
- </div>
771
- </div>
772
- </div>
773
- )
774
- })
775
- }
776
- `),await n(a(e,`app/[locale]/(shell)/admin/media/[id]/media-form.tsx`),`'use client'
777
-
778
- import { EntityForm } from '@murumets-ee/admin-ui/entity-form'
779
- import type { EntityMeta } from '@murumets-ee/admin-ui/entity-list'
780
- import { useRouter } from 'next/navigation'
781
-
782
- interface MediaFormProps {
783
- entity: EntityMeta
784
- id: string
785
- locale?: string
786
- initialData?: Record<string, unknown>
787
- }
788
-
789
- export function MediaForm({ entity, id, locale, initialData }: MediaFormProps) {
790
- const router = useRouter()
791
-
792
- return (
793
- <EntityForm
794
- entity={entity}
795
- id={id}
796
- locale={locale}
797
- initialData={initialData}
798
- entityPath="media"
799
- layout="two-column"
800
- fields={['title', 'alt', 'description', 'filename', 'mimeType', 'size', 'mediaType']}
801
- fieldOverrides={{
802
- filename: { readOnly: true, description: 'Original upload filename (read-only)' },
803
- mimeType: { readOnly: true, label: 'MIME Type' },
804
- size: { readOnly: true, description: 'File size in bytes' },
805
- mediaType: { readOnly: true, label: 'Media Type' },
806
- alt: { label: 'Alt Text', description: 'Alternative text for accessibility' },
807
- }}
808
- onSuccess={() => router.push(\`/\${locale}/admin/media\`)}
809
- onCancel={() => router.back()}
810
- />
811
- )
812
- }
813
- `),await n(a(e,`app/[locale]/(shell)/admin/media/[id]/media-usage-panel.tsx`),`import type { MediaUsage } from '@murumets-ee/media/usage'
814
-
815
- interface MediaUsagePanelProps {
816
- usages: MediaUsage[]
817
- locale: string
818
- }
819
-
820
- export function MediaUsagePanel({ usages, locale }: MediaUsagePanelProps) {
821
- return (
822
- <div className="rounded-lg border bg-card p-4">
823
- <h3 className="mb-3 text-sm font-semibold">Used by</h3>
824
-
825
- {usages.length === 0 && (
826
- <p className="text-sm text-muted-foreground">
827
- This media item is not referenced by any entity.
828
- </p>
829
- )}
830
-
831
- {usages.length > 0 && (
832
- <ul className="space-y-2">
833
- {usages.map((usage) => {
834
- const entitySlug = \`\${usage.entityName}s\`
835
- const href = \`/\${locale}/admin/\${entitySlug}/\${usage.entityId}\`
836
-
837
- return (
838
- <li key={\`\${usage.entityName}-\${usage.entityId}-\${usage.fieldName}\`}>
839
- <a
840
- href={href}
841
- className="block rounded-md border px-3 py-2 text-sm hover:bg-muted transition-colors"
842
- >
843
- <span className="font-medium capitalize">{usage.entityName}</span>
844
- <span className="mx-1.5 text-muted-foreground">&middot;</span>
845
- <span className="text-muted-foreground">{usage.fieldName}</span>
846
- {usage.context === 'block' && (
847
- <span className="ml-1.5 rounded bg-muted px-1.5 py-0.5 text-xs text-muted-foreground">
848
- block
849
- </span>
850
- )}
851
- </a>
852
- </li>
853
- )
854
- })}
855
- </ul>
856
- )}
857
- </div>
858
- )
859
- }
860
- `),await n(a(e,`app/[locale]/(shell)/admin/media/image-styles/page.tsx`),`import { imageStylesSettings } from '@murumets-ee/media'
861
- import { ImageStylesManager } from '@murumets-ee/media/image-styles'
862
- import { createSettingsClient } from '@murumets-ee/settings'
863
- import { setRequestLocale } from 'next-intl/server'
864
- import { getToolkitApp } from '@/lib/app'
865
-
866
- async function loadImageStyles() {
867
- const app = await getToolkitApp()
868
- const client = createSettingsClient(imageStylesSettings, { app })
869
- const styles = await client.get('imageStyles')
870
- return styles ?? {}
871
- }
872
-
873
- export default async function ImageStylesPage({ params }: { params: Promise<{ locale: string }> }) {
874
- const { locale } = await params
875
- setRequestLocale(locale)
876
-
877
- const initialStyles = await loadImageStyles()
878
-
879
- return (
880
- <div className="p-6 max-w-4xl">
881
- <ImageStylesManager initialStyles={initialStyles} />
882
- </div>
883
- )
884
- }
885
- `),await n(a(e,`app/[locale]/(shell)/admin/users/page.tsx`),`import type { UsersInitialData } from '@murumets-ee/admin-ui/users'
886
- import { getAuth } from '@murumets-ee/auth'
887
- import { headers } from 'next/headers'
888
- import { setRequestLocale } from 'next-intl/server'
889
- import { getToolkitApp } from '@/lib/app'
890
- import { UsersPage } from './users-page'
891
-
892
- export default async function UsersAdminPage({ params }: { params: Promise<{ locale: string }> }) {
893
- const { locale } = await params
894
- setRequestLocale(locale)
895
-
896
- await getToolkitApp()
897
- const auth = getAuth()
898
-
899
- const result = await auth.api.listUsers({
900
- headers: await headers(),
901
- query: { limit: 20, sortBy: 'createdAt', sortDirection: 'desc' },
902
- })
903
-
904
- const initialData: UsersInitialData = {
905
- users: result.users.map((u) => ({
906
- id: u.id,
907
- name: u.name,
908
- email: u.email,
909
- emailVerified: u.emailVerified,
910
- image: u.image ?? null,
911
- createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : String(u.createdAt),
912
- updatedAt: u.updatedAt instanceof Date ? u.updatedAt.toISOString() : String(u.updatedAt),
913
- role: u.role ?? null,
914
- banned: u.banned ?? null,
915
- banReason: u.banReason ?? null,
916
- banExpires:
917
- u.banExpires instanceof Date
918
- ? u.banExpires.toISOString()
919
- : u.banExpires
920
- ? String(u.banExpires)
921
- : null,
922
- })),
923
- total: result.total,
924
263
  }
925
264
 
926
- return (
927
- <div className="p-6">
928
- <div className="mb-6">
929
- <h1 className="text-2xl font-bold">Users</h1>
930
- <p className="text-sm text-muted-foreground">Manage user accounts, roles, and access.</p>
931
- </div>
932
- <UsersPage initialData={initialData} />
933
- </div>
934
- )
935
- }
936
- `),await n(a(e,`app/[locale]/(shell)/admin/users/users-page.tsx`),`'use client'
937
-
938
- import type { UsersInitialData } from '@murumets-ee/admin-ui/users'
939
- import { UsersManagement } from '@murumets-ee/admin-ui/users'
940
- import { createUsersApi } from '@murumets-ee/auth/client'
941
- import { authClient } from '@/lib/auth-client'
942
-
943
- const usersApi = createUsersApi(authClient)
944
-
945
- export function UsersPage({ initialData }: { initialData: UsersInitialData }) {
946
- const session = authClient.useSession()
947
-
948
- return (
949
- <UsersManagement
950
- api={usersApi}
951
- currentUserId={session.data?.user?.id}
952
- roles={['admin', 'editor', 'viewer']}
953
- initialData={initialData}
954
- />
955
- )
956
- }
957
- `),await n(a(e,`app/[locale]/(shell)/admin/permissions/page.tsx`),`import { PermissionsEditor } from '@murumets-ee/admin-ui/permissions'
958
- import { BUILT_IN_ROLES, buildResourceCatalog } from '@murumets-ee/auth'
959
- import { setRequestLocale } from 'next-intl/server'
960
- import { allEntities, pluginResources } from '@/lib/admin-config'
961
- import { getToolkitApp } from '@/lib/app'
962
- import { loadRoles } from '@/lib/load-roles'
963
-
964
- export default async function PermissionsPage({ params }: { params: Promise<{ locale: string }> }) {
965
- const { locale } = await params
966
- setRequestLocale(locale)
967
-
968
- const app = await getToolkitApp()
969
- const savedRoles = await loadRoles(app)
970
- const statements = buildResourceCatalog(allEntities, pluginResources)
971
-
972
- return (
973
- <div className="p-6">
974
- <div className="mb-6">
975
- <h1 className="text-2xl font-bold">Permissions</h1>
976
- <p className="text-sm text-muted-foreground">
977
- Configure what each role can do. Admin always has full access.
978
- </p>
979
- </div>
980
- <PermissionsEditor
981
- statements={statements}
982
- roles={Object.keys(savedRoles)}
983
- builtInRoles={[...BUILT_IN_ROLES]}
984
- initialPermissions={savedRoles}
985
- />
986
- </div>
987
- )
988
- }
989
- `),await n(a(e,`app/[locale]/(shell)/admin/roles/page.tsx`),`import { RolesEditor } from '@murumets-ee/admin-ui/permissions'
990
- import { BUILT_IN_ROLES } from '@murumets-ee/auth'
991
- import { setRequestLocale } from 'next-intl/server'
992
- import { getToolkitApp } from '@/lib/app'
993
- import { loadRoles } from '@/lib/load-roles'
994
-
995
- export default async function RolesPage({ params }: { params: Promise<{ locale: string }> }) {
996
- const { locale } = await params
997
- setRequestLocale(locale)
998
-
999
- const app = await getToolkitApp()
1000
- const savedRoles = await loadRoles(app)
1001
-
1002
- const roles = [
1003
- { name: 'admin', builtIn: true, permissionCount: 0 },
1004
- ...Object.entries(savedRoles).map(([name, perms]) => ({
1005
- name,
1006
- builtIn: (BUILT_IN_ROLES as readonly string[]).includes(name),
1007
- permissionCount: Object.values(perms).flat().length,
1008
- })),
1009
- ]
1010
-
1011
- return (
1012
- <div className="p-6">
1013
- <div className="mb-6">
1014
- <h1 className="text-2xl font-bold">Roles</h1>
1015
- <p className="text-sm text-muted-foreground">
1016
- Manage user roles. Built-in roles cannot be deleted.
1017
- </p>
1018
- </div>
1019
- <RolesEditor roles={roles} permissionsHref={\`/\${locale}/admin/permissions\`} />
1020
- </div>
1021
- )
1022
- }
1023
- `),await n(a(e,`app/[locale]/(shell)/admin/activity/page.tsx`),`import { AuditLog } from '@murumets-ee/admin-ui/audit-log'
1024
- import { fetchAuditLogData } from '@murumets-ee/admin-ui/server'
1025
- import { setRequestLocale } from 'next-intl/server'
1026
- import { withAdminContext } from '@/lib/with-admin-context'
1027
-
1028
- export default async function ActivityPage({ params }: { params: Promise<{ locale: string }> }) {
1029
- const { locale } = await params
1030
- setRequestLocale(locale)
1031
-
1032
- return withAdminContext(locale, async () => {
1033
- const initialData = await fetchAuditLogData()
1034
-
1035
- return (
1036
- <div className="p-6">
1037
- <div className="mb-6">
1038
- <h1 className="text-2xl font-bold">Activity Log</h1>
1039
- <p className="text-sm text-muted-foreground">
1040
- Track all content changes across your CMS.
1041
- </p>
1042
- </div>
1043
- <AuditLog
1044
- initialData={initialData}
1045
- editHrefPattern={\`/\${locale}/admin/:entityType/:entityId\`}
1046
- />
1047
- </div>
1048
- )
1049
- })
1050
- }
1051
- `),await n(a(e,`app/[locale]/(shell)/admin/tickets/page.tsx`),`import { Ticket, Department, TicketTag } from '@murumets-ee/ticketing'
1052
- import { headers } from 'next/headers'
1053
- import { setRequestLocale } from 'next-intl/server'
1054
- import { withAdminContext } from '@/lib/with-admin-context'
1055
- import { auth } from '@/lib/auth'
1056
- import { TicketsInboxClient } from './tickets-inbox-client'
1057
- import { fetchEntityList } from '@murumets-ee/admin-ui/server'
1058
- import type {
1059
- TicketData,
1060
- DepartmentData,
1061
- TagData,
1062
- } from '@murumets-ee/ticketing-ui'
1063
-
1064
- interface TicketsPageProps {
1065
- params: Promise<{ locale: string }>
1066
- }
1067
-
1068
- export default async function TicketsPage({ params }: TicketsPageProps) {
1069
- const { locale } = await params
1070
- setRequestLocale(locale)
1071
-
1072
- const h = await headers()
1073
- const session = await auth.api.getSession({ headers: h })
1074
- const currentUserId = session?.user?.id ?? ''
1075
-
1076
- return withAdminContext(locale, async () => {
1077
- const ticketData = await fetchEntityList(Ticket, {
1078
- sortField: 'lastReplyAt',
1079
- sortDirection: 'desc',
1080
- limit: 50,
1081
- })
1082
-
1083
- const departmentData = await fetchEntityList(Department, {
1084
- sortField: 'name',
1085
- sortDirection: 'asc',
1086
- limit: 100,
1087
- })
1088
-
1089
- const tagData = await fetchEntityList(TicketTag, {
1090
- sortField: 'name',
1091
- sortDirection: 'asc',
1092
- limit: 100,
1093
- })
1094
-
1095
- return (
1096
- <TicketsInboxClient
1097
- initialTickets={ticketData.items as unknown as TicketData[]}
1098
- initialTotal={ticketData.total}
1099
- initialDepartments={departmentData.items as unknown as DepartmentData[]}
1100
- initialTags={tagData.items as unknown as TagData[]}
1101
- currentUserId={currentUserId}
1102
- locale={locale}
1103
- />
1104
- )
1105
- })
1106
- }
1107
- `),await n(a(e,`app/[locale]/(shell)/admin/tickets/tickets-inbox-client.tsx`),`'use client'
1108
-
1109
- import { QueryProvider } from '@murumets-ee/admin-ui'
1110
- import { InboxProvider, TicketInbox } from '@murumets-ee/ticketing-ui/inbox'
1111
- import type {
1112
- TicketData,
1113
- DepartmentData,
1114
- TagData,
1115
- } from '@murumets-ee/ticketing-ui'
1116
-
1117
- interface TicketsInboxClientProps {
1118
- initialTickets: TicketData[]
1119
- initialTotal: number
1120
- initialDepartments: DepartmentData[]
1121
- initialTags: TagData[]
1122
- currentUserId: string
1123
- locale: string
1124
- }
1125
-
1126
- export function TicketsInboxClient({
1127
- initialTickets,
1128
- initialTotal,
1129
- initialDepartments,
1130
- initialTags,
1131
- currentUserId,
1132
- }: TicketsInboxClientProps) {
1133
- return (
1134
- <QueryProvider>
1135
- <InboxProvider
1136
- apiBasePath="/api/admin"
1137
- initialTickets={initialTickets}
1138
- initialTotal={initialTotal}
1139
- initialDepartments={initialDepartments}
1140
- initialTags={initialTags}
1141
- currentUserId={currentUserId}
1142
- >
1143
- <div className="h-[calc(100vh-3.5rem)]">
1144
- <TicketInbox currentUserId={currentUserId} />
1145
- </div>
1146
- </InboxProvider>
1147
- </QueryProvider>
1148
- )
1149
- }
1150
- `),await n(a(e,`app/[locale]/(shell)/admin/tickets/board/page.tsx`),`import { Ticket, Department } from '@murumets-ee/ticketing'
1151
- import { setRequestLocale } from 'next-intl/server'
1152
- import { withAdminContext } from '@/lib/with-admin-context'
1153
- import { fetchEntityList } from '@murumets-ee/admin-ui/server'
1154
- import { TicketBoardClient } from './ticket-board-client'
1155
- import type { TicketData, DepartmentData } from '@murumets-ee/ticketing-ui'
1156
-
1157
- interface TicketBoardPageProps {
1158
- params: Promise<{ locale: string }>
1159
- }
1160
-
1161
- export default async function TicketBoardPage({ params }: TicketBoardPageProps) {
1162
- const { locale } = await params
1163
- setRequestLocale(locale)
1164
-
1165
- return withAdminContext(locale, async () => {
1166
- const ticketData = await fetchEntityList(Ticket, {
1167
- sortField: 'lastReplyAt',
1168
- sortDirection: 'desc',
1169
- limit: 200,
1170
- })
1171
-
1172
- const departmentData = await fetchEntityList(Department, {
1173
- sortField: 'name',
1174
- sortDirection: 'asc',
1175
- limit: 100,
1176
- })
1177
-
1178
- return (
1179
- <div className="p-6 h-[calc(100vh-3.5rem)]">
1180
- <div className="mb-4">
1181
- <h1 className="text-2xl font-bold">Ticket Board</h1>
1182
- <p className="text-sm text-muted-foreground">
1183
- Kanban view of tickets by status
1184
- </p>
1185
- </div>
1186
- <TicketBoardClient
1187
- initialTickets={ticketData.items as unknown as TicketData[]}
1188
- initialDepartments={departmentData.items as unknown as DepartmentData[]}
1189
- locale={locale}
1190
- />
1191
- </div>
1192
- )
1193
- })
1194
- }
1195
- `),await n(a(e,`app/[locale]/(shell)/admin/tickets/board/ticket-board-client.tsx`),`'use client'
265
+ const pgCoreTypes = ['pgTable', 'varchar', 'text', 'boolean', 'timestamp', 'integer', 'doublePrecision', 'jsonb', 'uuid', 'index']
266
+ if (hasTranslations) pgCoreTypes.push('unique')
267
+ const pgCoreImports = \`import { \${pgCoreTypes.join(', ')} } from 'drizzle-orm/pg-core'\\n\`
268
+ imports.push(pgCoreImports)
1196
269
 
1197
- import { useMemo } from 'react'
1198
- import { TicketBoard } from '@murumets-ee/ticketing-ui/board'
1199
- import type { TicketData, DepartmentData } from '@murumets-ee/ticketing-ui'
1200
- import { useRouter } from 'next/navigation'
270
+ const schemaFile = join(schemaDir, 'schema.ts')
271
+ await writeFile(schemaFile, [...imports, ...schemas].join('\\n'))
1201
272
 
1202
- interface TicketBoardClientProps {
1203
- initialTickets: TicketData[]
1204
- initialDepartments: DepartmentData[]
1205
- locale: string
273
+ console.log(\`\\nSchemas written to: \${schemaFile}\`)
274
+ console.log('Run \\\`pnpm db:migrate:generate\\\` to create migrations')
1206
275
  }
1207
276
 
1208
- export function TicketBoardClient({
1209
- initialTickets,
1210
- initialDepartments,
1211
- locale,
1212
- }: TicketBoardClientProps) {
1213
- const router = useRouter()
1214
- const departmentMap = useMemo(
1215
- () => new Map(initialDepartments.map((d) => [d.id, d])),
1216
- [initialDepartments],
1217
- )
1218
-
1219
- return (
1220
- <TicketBoard
1221
- tickets={initialTickets}
1222
- departments={departmentMap}
1223
- onTicketSelect={(ticket) => {
1224
- router.push(\`/\${locale}/admin/tickets?selected=\${ticket.id}\`)
1225
- }}
1226
- className="h-[calc(100%-4rem)]"
1227
- />
1228
- )
1229
- }
1230
- `),await n(a(e,`app/api/ticketing/inbound/route.ts`),`/**
1231
- * Resend inbound email webhook endpoint.
1232
- *
1233
- * Public endpoint (no session auth) — protected by webhook signature
1234
- * verification (HMAC-SHA256). Lives outside the admin API handler
1235
- * because the admin handler requires authentication.
277
+ generateSchemas().catch(console.error)
278
+ `),await e(d(i,`scripts/migrate.ts`),`/**
279
+ * Run pending migrations
1236
280
  */
1237
281
 
1238
- import { getToolkitApp } from '@/lib/app'
1239
-
1240
- await getToolkitApp()
1241
-
1242
- export async function POST(req: Request): Promise<Response> {
1243
- const { handleInboundWebhook } = await import('@murumets-ee/ticketing')
1244
- const { QueueClient } = await import('@murumets-ee/queue/client')
1245
- const { getApp } = await import('@murumets-ee/core')
1246
-
1247
- const db = getApp().db.readWrite
1248
- const queueClient = new QueueClient({ db })
1249
-
1250
- return handleInboundWebhook(req, (type, payload) => queueClient.enqueue(type, payload))
1251
- }
1252
- `),await n(a(e,`app/[locale]/(shell)/admin/seed/actions.ts`),`'use server'
1253
-
1254
- import { AdminClient } from '@murumets-ee/entity/admin'
1255
- import { createTaxonomyClient } from '@murumets-ee/taxonomy/client'
1256
- import { Ticket, TicketMessage, Department, TicketTag } from '@murumets-ee/ticketing'
1257
- import { headers } from 'next/headers'
1258
- import { Article, Category } from '@/entities'
1259
- import { getToolkitApp } from '@/lib/app'
1260
- import { auth } from '@/lib/auth'
1261
-
1262
- async function requireAdmin() {
1263
- const session = await auth.api.getSession({ headers: await headers() })
1264
- if (!session?.user) throw new Error('Unauthorized')
1265
- const role = (session.user as Record<string, unknown>).role as string | undefined
1266
- if (role !== 'admin') throw new Error('Forbidden: admin role required')
1267
- }
1268
-
1269
- export async function seedContent() {
1270
- if (process.env.NODE_ENV === 'production') {
1271
- return { error: 'Seed disabled in production' }
1272
- }
1273
-
1274
- await requireAdmin()
1275
- const app = await getToolkitApp()
1276
- const categories = createTaxonomyClient(Category)
1277
- const articles = new AdminClient({
1278
- entity: Article,
1279
- db: app.db.readWrite,
1280
- logger: app.logger.child({ entity: 'article' }),
1281
- })
1282
-
1283
- const techCat = await categories.create({ name: 'Technology', slug: 'technology', color: '#3B82F6' })
1284
- const newsCat = await categories.create({ name: 'News', slug: 'news', color: '#EF4444' })
1285
-
1286
- await articles.create({
1287
- title: 'Getting Started with Lumi CMS',
1288
- slug: 'getting-started',
1289
- excerpt: 'Learn how to build apps with the toolkit',
1290
- body: [{ type: 'p', children: [{ text: 'A guide to getting started with Lumi CMS.' }] }],
1291
- category: techCat.id as string,
1292
- status: 'published',
1293
- publishedAt: new Date(),
1294
- })
282
+ import { createDbClient, runMigrations } from '@murumets-ee/db'
1295
283
 
1296
- await articles.create({
1297
- title: 'Lumi CMS v1.0 Released',
1298
- slug: 'v1-released',
1299
- excerpt: 'We are excited to announce version 1.0',
1300
- body: [{ type: 'p', children: [{ text: 'After months of development, v1.0 is here.' }] }],
1301
- category: newsCat.id as string,
1302
- status: 'published',
1303
- publishedAt: new Date(),
1304
- })
284
+ async function migrate() {
285
+ console.log('Running migrations...')
1305
286
 
1306
- return { ok: true, created: { categories: 2, articles: 2 } }
1307
- }
1308
-
1309
- export async function seedTicketing() {
1310
- if (process.env.NODE_ENV === 'production') {
1311
- return { error: 'Seed disabled in production' }
1312
- }
1313
-
1314
- await requireAdmin()
1315
- const app = await getToolkitApp()
1316
-
1317
- const deptClient = createTaxonomyClient(Department)
1318
- const tagClient = createTaxonomyClient(TicketTag)
1319
- const ticketAdmin = new AdminClient({
1320
- entity: Ticket,
1321
- db: app.db.readWrite,
1322
- logger: app.logger.child({ entity: 'ticket' }),
1323
- })
1324
- const messageAdmin = new AdminClient({
1325
- entity: TicketMessage,
1326
- db: app.db.readWrite,
1327
- logger: app.logger.child({ entity: 'ticket_message' }),
1328
- })
1329
-
1330
- const support = await deptClient.create({
1331
- name: 'General Support', slug: 'general-support', color: '#3B82F6',
1332
- description: 'General customer support inquiries',
1333
- emailAddress: 'support@example.com',
1334
- slaFirstResponseHours: 4, slaResolutionHours: 24,
1335
- })
1336
- const billing = await deptClient.create({
1337
- name: 'Billing', slug: 'billing', color: '#10B981',
1338
- description: 'Payment and invoice questions',
1339
- emailAddress: 'billing@example.com',
1340
- slaFirstResponseHours: 2, slaResolutionHours: 12,
1341
- })
1342
- const technical = await deptClient.create({
1343
- name: 'Technical', slug: 'technical', color: '#8B5CF6',
1344
- description: 'Technical issues and bug reports',
1345
- emailAddress: 'tech@example.com',
1346
- slaFirstResponseHours: 1, slaResolutionHours: 8,
1347
- })
1348
-
1349
- const bugTag = await tagClient.create({ name: 'Bug', slug: 'bug', color: '#EF4444' })
1350
- const featureTag = await tagClient.create({ name: 'Feature Request', slug: 'feature-request', color: '#F59E0B' })
1351
- const urgentTag = await tagClient.create({ name: 'Urgent', slug: 'urgent', color: '#DC2626' })
1352
- await tagClient.create({ name: 'Feedback', slug: 'feedback', color: '#06B6D4' })
1353
- await tagClient.create({ name: 'Documentation', slug: 'documentation', color: '#6366F1' })
1354
-
1355
- const now = Date.now()
1356
- let counter = 0
1357
-
1358
- async function createTicket(opts: {
1359
- subject: string; department: string; status: string; priority: string;
1360
- requesterEmail: string; requesterName: string; tags?: string[];
1361
- messages: Array<{ body: string; senderType: string; senderEmail: string; senderName: string; visibility?: string; minutesAgo: number }>
1362
- }) {
1363
- counter++
1364
- const lastMsg = opts.messages[opts.messages.length - 1]
1365
- const ticket = await ticketAdmin.create({
1366
- subject: opts.subject, status: opts.status, priority: opts.priority,
1367
- department: opts.department, requesterEmail: opts.requesterEmail,
1368
- requesterName: opts.requesterName,
1369
- ticketNumber: \`TK-\${String(counter).padStart(6, '0')}\`,
1370
- lastReplyAt: new Date(now - lastMsg.minutesAgo * 60_000),
1371
- lastReplierType: lastMsg.senderType,
1372
- tags: (opts.tags ?? []) as never,
1373
- })
1374
- for (const msg of opts.messages) {
1375
- await messageAdmin.create({
1376
- ticket: ticket.id, body: msg.body, bodyFormat: 'html',
1377
- senderType: msg.senderType, senderEmail: msg.senderEmail,
1378
- senderName: msg.senderName, visibility: msg.visibility ?? 'public',
1379
- })
1380
- }
1381
- return ticket
1382
- }
1383
-
1384
- await createTicket({
1385
- subject: 'API returns 500 error on file upload',
1386
- department: technical.id as string, status: 'open', priority: 'high',
1387
- requesterEmail: 'john@acme.com', requesterName: 'John Smith',
1388
- tags: [bugTag.id as string, urgentTag.id as string],
1389
- messages: [
1390
- { body: '<p>When I upload files larger than 5MB, I get a 500 error. Started after the last update.</p>', senderType: 'customer', senderEmail: 'john@acme.com', senderName: 'John Smith', minutesAgo: 120 },
1391
- { body: '<p>Thanks for reporting this. I can see the error in our logs. Escalating to backend team.</p>', senderType: 'agent', senderEmail: 'sarah@support.com', senderName: 'Sarah Chen', minutesAgo: 90 },
1392
- { body: '<p>Any update? We have a deadline tomorrow.</p>', senderType: 'customer', senderEmail: 'john@acme.com', senderName: 'John Smith', minutesAgo: 30 },
1393
- ],
1394
- })
1395
-
1396
- await createTicket({
1397
- subject: 'Invoice #2024-0847 has incorrect amount',
1398
- department: billing.id as string, status: 'pending', priority: 'normal',
1399
- requesterEmail: 'maria@startup.io', requesterName: 'Maria Garcia',
1400
- messages: [
1401
- { body: '<p>Invoice #2024-0847 shows $299 instead of the agreed $199. Please correct.</p>', senderType: 'customer', senderEmail: 'maria@startup.io', senderName: 'Maria Garcia', minutesAgo: 1440 },
1402
- { body: '<p>Apologies for the discrepancy. Checking with billing team, should be resolved in 24h.</p>', senderType: 'agent', senderEmail: 'alex@support.com', senderName: 'Alex Johnson', minutesAgo: 1380 },
1403
- ],
1404
- })
1405
-
1406
- await createTicket({
1407
- subject: 'Production site down — 503 errors',
1408
- department: technical.id as string, status: 'open', priority: 'urgent',
1409
- requesterEmail: 'ops@enterprise.co', requesterName: 'Emma Wilson',
1410
- tags: [bugTag.id as string, urgentTag.id as string],
1411
- messages: [
1412
- { body: '<p><strong>URGENT</strong> — Production returning 503 for all users. Need immediate help.</p>', senderType: 'customer', senderEmail: 'ops@enterprise.co', senderName: 'Emma Wilson', minutesAgo: 15 },
1413
- ],
1414
- })
1415
-
1416
- await createTicket({
1417
- subject: 'Can we get dark mode for the dashboard?',
1418
- department: support.id as string, status: 'resolved', priority: 'low',
1419
- requesterEmail: 'dev@techcorp.com', requesterName: 'David Lee',
1420
- tags: [featureTag.id as string],
1421
- messages: [
1422
- { body: '<p>Would it be possible to add dark mode? Our team works late hours.</p>', senderType: 'customer', senderEmail: 'dev@techcorp.com', senderName: 'David Lee', minutesAgo: 10080 },
1423
- { body: '<p>Dark mode just shipped! Toggle it in Settings → Appearance.</p>', senderType: 'agent', senderEmail: 'sarah@support.com', senderName: 'Sarah Chen', minutesAgo: 4320 },
1424
- { body: '<p>Perfect, exactly what we needed. Thanks!</p>', senderType: 'customer', senderEmail: 'dev@techcorp.com', senderName: 'David Lee', minutesAgo: 4200 },
1425
- ],
1426
- })
1427
-
1428
- return { ok: true, created: { departments: 3, tags: 5, tickets: 4, messages: 9 } }
1429
- }
1430
- `),await n(a(e,`app/[locale]/(shell)/admin/seed/page.tsx`),`'use client'
1431
-
1432
- import { Button } from '@murumets-ee/ui'
1433
- import { useState } from 'react'
1434
- import { seedContent, seedTicketing } from './actions'
1435
-
1436
- export default function SeedPage() {
1437
- const [result, setResult] = useState<string | null>(null)
1438
- const [loading, setLoading] = useState(false)
1439
-
1440
- async function run(action: () => Promise<unknown>) {
1441
- setLoading(true)
1442
- setResult(null)
1443
- try {
1444
- const res = await action()
1445
- setResult(JSON.stringify(res, null, 2))
1446
- } catch (e) {
1447
- setResult(\`Error: \${e instanceof Error ? e.message : String(e)}\`)
1448
- } finally {
1449
- setLoading(false)
1450
- }
1451
- }
1452
-
1453
- return (
1454
- <div className="max-w-2xl mx-auto p-8">
1455
- <h1 className="text-2xl font-bold mb-2">Seed Data</h1>
1456
- <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
1457
- Create demo content for testing. Only available in development mode.
1458
- </p>
1459
-
1460
- <div className="flex gap-3 flex-wrap">
1461
- <Button onClick={() => run(seedContent)} loading={loading}>
1462
- Seed Content
1463
- </Button>
1464
- <Button onClick={() => run(seedTicketing)} loading={loading} variant="secondary">
1465
- Seed Ticketing
1466
- </Button>
1467
- </div>
1468
-
1469
- {result && (
1470
- <pre className="mt-6 bg-zinc-100 dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-lg p-4 text-sm overflow-auto">
1471
- {result}
1472
- </pre>
1473
- )}
1474
- </div>
1475
- )
1476
- }
1477
- `),await n(a(e,`app/api/admin/[...path]/route.ts`),`import { createAdminApiHandler } from '@murumets-ee/admin-ui/server'
1478
- import { buildPermissionChecker, buildResourceCatalog } from '@murumets-ee/auth'
1479
- import { permissionRoutes } from '@murumets-ee/auth/admin'
1480
- import { ContentClient } from '@murumets-ee/content/client'
1481
- import { LockService } from '@murumets-ee/content/lock'
1482
- import type { PermissionChecker } from '@murumets-ee/core'
1483
- import { getApp } from '@murumets-ee/core'
1484
- import {
1485
- AuditLogClient,
1486
- createAuditDbWriter,
1487
- createAuditLogger,
1488
- createLogger,
1489
- } from '@murumets-ee/logging'
1490
- import { logRoutes } from '@murumets-ee/logging/admin'
1491
- import { mediaRoutes } from '@murumets-ee/media/admin'
1492
- import { createSettingsClient } from '@murumets-ee/settings'
1493
- import { settingsRoutes } from '@murumets-ee/settings/admin'
1494
- import { storageRoutes } from '@murumets-ee/storage/admin'
1495
- import { taxonomyRoutes } from '@murumets-ee/taxonomy/admin'
1496
- import { ticketingRoutes } from '@murumets-ee/ticketing/admin'
1497
- import {
1498
- allEntities,
1499
- crudEntities,
1500
- pluginResources,
1501
- taxonomyVocabularies,
1502
- } from '@/lib/admin-config'
1503
- import { getToolkitApp } from '@/lib/app'
1504
- import { auth } from '@/lib/auth'
1505
- import { loadRoles } from '@/lib/load-roles'
1506
- import { permissionSettings } from '@/settings/permissions'
1507
- import { siteSettings } from '@/settings/site'
1508
-
1509
- await getToolkitApp()
1510
-
1511
- let _auditLogger: ReturnType<typeof createAuditLogger> | null = null
1512
- let _checker: PermissionChecker | null = null
1513
-
1514
- const routes = [
1515
- mediaRoutes(),
1516
- storageRoutes(),
1517
- settingsRoutes(siteSettings),
1518
- taxonomyRoutes(taxonomyVocabularies),
1519
- ticketingRoutes(),
1520
- logRoutes(() => new AuditLogClient(getApp().db.readWrite)),
1521
- permissionRoutes({
1522
- getStatements: () => buildResourceCatalog(allEntities, pluginResources),
1523
- loadRoles: async () => loadRoles(getApp()),
1524
- saveRoles: async (roles) => {
1525
- const app = getApp()
1526
- const client = createSettingsClient(permissionSettings, { app })
1527
- await client.set('roles', roles)
1528
- },
1529
- onSave: () => {
1530
- _checker = null
1531
- },
1532
- }),
1533
- ]
1534
-
1535
- const handler = createAdminApiHandler({
1536
- authenticate: async (req) => {
1537
- const session = await auth.api.getSession({ headers: req.headers })
1538
- if (!session?.user) return null
1539
- const role = (session.user as Record<string, unknown>).role as string | undefined
1540
- return { id: session.user.id, role, name: session.user.name, email: session.user.email }
1541
- },
1542
- defaultLocale: 'en',
1543
- entities: crudEntities,
1544
- routes,
1545
- loadPermissions: async () => {
1546
- if (_checker) return _checker
1547
- const roles = await loadRoles(getApp())
1548
- _checker = buildPermissionChecker(roles)
1549
- return _checker
1550
- },
1551
- auditLogger: {
1552
- log: async (entry) => {
1553
- if (!_auditLogger) {
1554
- const app = getApp()
1555
- _auditLogger = createAuditLogger({
1556
- logger: createLogger({ name: 'audit' }),
1557
- dbWriter: createAuditDbWriter(app.db.readWrite),
1558
- })
1559
- }
1560
- return _auditLogger.log(entry)
1561
- },
1562
- },
1563
- contentClientFactory: (entity) =>
1564
- new ContentClient({ entity, db: getApp().db.readWrite }),
1565
- lockServiceFactory: () =>
1566
- new LockService({ db: getApp().db.readWrite }),
1567
- })
1568
-
1569
- export const { GET, POST, PATCH, DELETE } = handler
1570
- `)}async function c(e,t){await n(a(e,`lib/auth.ts`),`import { getAuth } from '@murumets-ee/auth'
1571
- import { getToolkitApp } from './app'
1572
-
1573
- await getToolkitApp()
1574
-
1575
- export const auth = getAuth()
1576
- `),await n(a(e,`lib/auth-client.ts`),`import { createClient } from '@murumets-ee/auth/client'
1577
-
1578
- export const authClient = createClient()
1579
- `),await n(a(e,`app/api/auth/[...all]/route.ts`),`import { toNextJsHandler } from 'better-auth/next-js'
1580
- import { auth } from '@/lib/auth'
1581
-
1582
- const { GET, POST } = toNextJsHandler(auth)
1583
- export { GET, POST }
1584
- `),await n(a(e,`app/[locale]/auth/layout.tsx`),`import { AuthProviders } from './providers'
1585
- import type { ReactNode } from 'react'
1586
-
1587
- export default function AuthLayout({ children }: { children: ReactNode }) {
1588
- return (
1589
- <AuthProviders>
1590
- <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
1591
- {children}
1592
- </div>
1593
- </AuthProviders>
1594
- )
1595
- }
1596
- `),await n(a(e,`app/[locale]/auth/providers.tsx`),`'use client'
1597
-
1598
- import { AuthUIProvider } from '@murumets-ee/auth-ui'
1599
- import { authClient } from '@/lib/auth-client'
1600
- import Link from 'next/link'
1601
- import { useRouter } from 'next/navigation'
1602
- import { useLocale } from 'next-intl'
1603
- import type { ReactNode } from 'react'
1604
-
1605
- export function AuthProviders({ children }: { children: ReactNode }) {
1606
- const router = useRouter()
1607
- const locale = useLocale()
1608
-
1609
- return (
1610
- <AuthUIProvider
1611
- authClient={authClient as never}
1612
- basePath="/auth"
1613
- redirectTo="/"
1614
- Link={Link}
1615
- navigate={(url) => router.push(url)}
1616
- resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
1617
- >
1618
- {children}
1619
- </AuthUIProvider>
1620
- )
1621
- }
1622
- `),await n(a(e,`app/[locale]/auth/sign-in/page.tsx`),`import { SignInForm } from '@murumets-ee/auth-ui'
1623
-
1624
- export default function SignInPage() {
1625
- return <SignInForm />
1626
- }
1627
- `),await n(a(e,`app/[locale]/auth/sign-up/page.tsx`),`import { SignUpForm } from '@murumets-ee/auth-ui'
1628
-
1629
- export default function SignUpPage() {
1630
- return <SignUpForm />
1631
- }
1632
- `),await n(a(e,`app/[locale]/auth/forgot-password/page.tsx`),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
1633
-
1634
- export default function ForgotPasswordPage() {
1635
- return <ForgotPasswordForm />
1636
- }
1637
- `),await n(a(e,`app/[locale]/auth/reset-password/page.tsx`),`import { Suspense } from 'react'
1638
- import { ResetPasswordContent } from './content'
1639
-
1640
- export default function ResetPasswordPage() {
1641
- return (
1642
- <Suspense>
1643
- <ResetPasswordContent />
1644
- </Suspense>
1645
- )
1646
- }
1647
- `),await n(a(e,`app/[locale]/auth/reset-password/content.tsx`),`'use client'
1648
-
1649
- import { useSearchParams } from 'next/navigation'
1650
- import { ResetPasswordForm } from '@murumets-ee/auth-ui'
1651
-
1652
- export function ResetPasswordContent() {
1653
- const searchParams = useSearchParams()
1654
- const token = searchParams.get('token')
1655
-
1656
- if (!token) {
1657
- return (
1658
- <div className="text-center">
1659
- <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
1660
- Invalid or expired link
1661
- </h1>
1662
- <p className="mt-2 text-zinc-500">
1663
- Please request a new password reset.
1664
- </p>
1665
- </div>
1666
- )
1667
- }
1668
-
1669
- return <ResetPasswordForm token={token} />
1670
- }
1671
- `),await n(a(e,`app/[locale]/setup/page.tsx`),`import { redirect } from 'next/navigation'
1672
- import { sql } from 'drizzle-orm'
1673
- import { getToolkitApp } from '@/lib/app'
1674
- import { SetupForm } from './form'
1675
-
1676
- export default async function SetupPage() {
1677
- const app = await getToolkitApp()
1678
- const result = await app.db.readOnly.execute<{ count: string }>(
1679
- sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1680
- )
1681
-
1682
- if (Number(result[0]?.count) > 0) {
1683
- redirect('/')
1684
- }
1685
-
1686
- return (
1687
- <div className="max-w-md mx-auto p-8">
1688
- <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
1689
- <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
1690
- No users found. Create the first admin account.
1691
- </p>
1692
- <SetupForm />
1693
- </div>
1694
- )
1695
- }
1696
- `),await n(a(e,`app/[locale]/setup/form.tsx`),`'use client'
1697
-
1698
- import { useState } from 'react'
1699
- import { createFirstAdmin } from './actions'
1700
-
1701
- export function SetupForm() {
1702
- const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
1703
- const [loading, setLoading] = useState(false)
1704
-
1705
- async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
1706
- e.preventDefault()
1707
- setLoading(true)
1708
- setResult(null)
1709
- const res = await createFirstAdmin(new FormData(e.currentTarget))
1710
- setResult(res)
1711
- setLoading(false)
1712
- }
1713
-
1714
- if (result?.ok) {
1715
- return (
1716
- <div className="space-y-2">
1717
- <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
1718
- <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
1719
- Sign in &rarr;
1720
- </a>
1721
- </div>
1722
- )
1723
- }
1724
-
1725
- return (
1726
- <form onSubmit={handleSubmit} className="flex flex-col gap-4">
1727
- <label className="text-sm font-medium">
1728
- Name
1729
- <input
1730
- name="name"
1731
- required
1732
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1733
- />
1734
- </label>
1735
- <label className="text-sm font-medium">
1736
- Email
1737
- <input
1738
- name="email"
1739
- type="email"
1740
- required
1741
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1742
- />
1743
- </label>
1744
- <label className="text-sm font-medium">
1745
- Password (min 8 chars)
1746
- <input
1747
- name="password"
1748
- type="password"
1749
- required
1750
- minLength={8}
1751
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
1752
- />
1753
- </label>
1754
-
1755
- {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
1756
-
1757
- <button
1758
- type="submit"
1759
- disabled={loading}
1760
- className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
1761
- >
1762
- {loading ? 'Creating...' : 'Create Admin'}
1763
- </button>
1764
- </form>
1765
- )
1766
- }
1767
- `),await n(a(e,`app/[locale]/setup/actions.ts`),`'use server'
1768
-
1769
- import { sql } from 'drizzle-orm'
1770
- import { getToolkitApp } from '@/lib/app'
1771
- import { getAuth } from '@murumets-ee/auth'
1772
-
1773
- /**
1774
- * Create the first admin user. Locked down:
1775
- * - Advisory lock prevents race conditions (two simultaneous requests)
1776
- * - User count check inside the lock ensures only one admin can be created
1777
- * - Once any user exists, this action permanently refuses
1778
- */
1779
- export async function createFirstAdmin(formData: FormData) {
1780
- const email = formData.get('email') as string
1781
- const password = formData.get('password') as string
1782
- const name = formData.get('name') as string
1783
-
1784
- if (!email || !password || !name) {
1785
- return { error: 'All fields are required' }
1786
- }
1787
-
1788
- if (password.length < 8) {
1789
- return { error: 'Password must be at least 8 characters' }
1790
- }
1791
-
1792
- const app = await getToolkitApp()
1793
-
1794
- // Acquire advisory lock + check atomically in a transaction
1795
- const canCreate = await app.db.readWrite.transaction(async (tx) => {
1796
- // Advisory lock 1 = "setup lock". Blocks concurrent setup attempts.
1797
- await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
1798
-
1799
- const result = await tx.execute<{ count: string }>(
1800
- sql\`SELECT COUNT(*)::text as count FROM "user"\`,
1801
- )
1802
- return Number(result[0]?.count) === 0
1803
- })
1804
-
1805
- if (!canCreate) {
1806
- return { error: 'Admin user already exists. Setup is complete.' }
1807
- }
1808
-
1809
- const auth = getAuth()
1810
-
1811
- const adminUser = await auth.api.signUpEmail({
1812
- body: { email, password, name },
1813
- })
1814
-
1815
- await app.db.readWrite.execute(
1816
- sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
1817
- )
1818
-
1819
- return { ok: true, email: adminUser.user.email }
1820
- }
1821
- `)}async function l(e,t){let{name:r}=t;await n(a(e,`.env.example`),`DATABASE_URL=postgresql://${r}:${r}_dev_password@localhost:5432/${r}_dev
1822
- BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
1823
- BETTER_AUTH_URL=http://localhost:3000
1824
- LOG_LEVEL=debug
1825
- NEXT_PUBLIC_APP_URL=http://localhost:3000
1826
- # Queue worker — by default runs embedded in the web process.
1827
- # Set to "false" in production and run "pnpm worker" as a separate process.
1828
- # QUEUE_WORKER=false
1829
- RESEND_API_KEY=
1830
- RESEND_WEBHOOK_SECRET=
1831
- MAIL_FROM=noreply@example.com
1832
- CSAT_SECRET=
1833
- `),await n(a(e,`.dockerignore`),`# Dependencies (installed inside Docker)
1834
- node_modules/
1835
- .pnpm-store/
1836
-
1837
- # Build outputs (rebuilt inside Docker)
1838
- .next/
1839
- out/
1840
- dist/
1841
-
1842
- # Git
1843
- .git/
1844
- .gitignore
1845
-
1846
- # Environment files (pass via docker run --env-file)
1847
- .env
1848
- .env.*
1849
- !.env.example
1850
-
1851
- # Docker files
1852
- Dockerfile*
1853
- .dockerignore
1854
- docker-compose*.yml
1855
-
1856
- # Tests
1857
- coverage/
1858
- **/*.test.*
1859
- **/*.spec.*
1860
- **/vitest.config.*
1861
- docker-compose.test.yml
1862
-
1863
- # Development tooling
1864
- .vscode/
1865
- .idea/
1866
- .turbo/
1867
- .cache/
1868
- *.tsbuildinfo
1869
-
1870
- # Documentation
1871
- *.md
1872
- docs/
1873
-
1874
- # OS files
1875
- .DS_Store
1876
- Thumbs.db
1877
- `),await n(a(e,`Dockerfile`),`# --- Base image ---
1878
- # Node 22 LTS (Debian slim — glibc required for sharp image optimization)
1879
- FROM node:22-slim AS base
1880
-
1881
- # --- Stage 1: Install dependencies ---
1882
- FROM base AS deps
1883
- WORKDIR /app
1884
- RUN corepack enable pnpm
1885
- COPY package.json pnpm-lock.yaml ./
1886
- RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \\
1887
- pnpm install --frozen-lockfile
1888
-
1889
- # --- Stage 2: Build the application ---
1890
- FROM base AS builder
1891
- WORKDIR /app
1892
- RUN corepack enable pnpm
1893
- COPY --from=deps /app/node_modules ./node_modules
1894
- COPY . .
1895
- ENV NODE_ENV=production
1896
- ENV NEXT_TELEMETRY_DISABLED=1
1897
- # Dummy env vars so toolkit.config.ts doesn't throw at build time (no DB connection needed)
1898
- ENV DATABASE_URL=postgresql://build:build@localhost:5432/build
1899
- ENV BETTER_AUTH_SECRET=build-secret-not-used-at-runtime
1900
- RUN pnpm build
1901
-
1902
- # --- Stage 3: Production runner ---
1903
- FROM base AS runner
1904
- WORKDIR /app
1905
- ENV NODE_ENV=production
1906
- ENV PORT=3000
1907
- ENV HOSTNAME=0.0.0.0
1908
- ENV NEXT_TELEMETRY_DISABLED=1
1909
-
1910
- # Copy standalone output (includes server.js + traced node_modules)
1911
- COPY --from=builder --chown=node:node /app/.next/standalone ./
1912
- # Copy static assets (JS/CSS chunks — excluded from standalone trace)
1913
- COPY --from=builder --chown=node:node /app/.next/static ./.next/static
1914
- # Copy public assets (favicon, robots.txt, images)
1915
- COPY --from=builder --chown=node:node /app/public ./public
1916
- # Copy migrations (applied at app startup by the toolkit migration runner)
1917
- COPY --from=builder --chown=node:node /app/migrations ./migrations
1918
-
1919
- # Run as non-root user
1920
- USER node
1921
- EXPOSE 3000
1922
- CMD ["node", "server.js"]
1923
- `),await n(a(e,`docker-compose.yml`),`services:
1924
- postgres:
1925
- image: postgres:17-alpine
1926
- container_name: ${r}-postgres
1927
- restart: unless-stopped
1928
- environment:
1929
- POSTGRES_USER: ${r}
1930
- POSTGRES_PASSWORD: ${r}_dev_password
1931
- POSTGRES_DB: ${r}_dev
1932
- ports:
1933
- - "5432:5432"
1934
- volumes:
1935
- - postgres_data:/var/lib/postgresql/data
1936
- healthcheck:
1937
- test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}_dev"]
1938
- interval: 10s
1939
- timeout: 5s
1940
- retries: 5
1941
-
1942
- volumes:
1943
- postgres_data:
1944
- name: ${r}_postgres_data
1945
- `),await n(a(e,`docker-compose.prod.yml`),`services:
1946
- app:
1947
- build: .
1948
- restart: unless-stopped
1949
- env_file: .env
1950
- depends_on:
1951
- postgres:
1952
- condition: service_healthy
1953
- ports:
1954
- - "3000:3000"
1955
- networks:
1956
- - internal
1957
-
1958
- postgres:
1959
- image: postgres:17-alpine
1960
- restart: unless-stopped
1961
- environment:
1962
- POSTGRES_USER: ${r}
1963
- POSTGRES_PASSWORD: \${DB_PASSWORD}
1964
- POSTGRES_DB: ${r}
1965
- volumes:
1966
- - postgres_data:/var/lib/postgresql/data
1967
- healthcheck:
1968
- test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}"]
1969
- interval: 10s
1970
- timeout: 5s
1971
- retries: 5
1972
- networks:
1973
- - internal
1974
-
1975
- volumes:
1976
- postgres_data:
1977
-
1978
- networks:
1979
- internal:
1980
- `),await i(a(e,`.gitignore`),`
1981
- # Toolkit generated files
1982
- generated/
1983
-
1984
- # Environment
1985
- .env*
1986
- !.env.example
1987
- `)}async function u(e,t){await n(a(e,`entities/index.ts`),`export { Article } from './article'
1988
- export { Category } from './category'
1989
- `),await n(a(e,`entities/article.ts`),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
1990
-
1991
- export const Article = defineEntity({
1992
- name: 'article',
1993
- fields: {
1994
- title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
1995
- slug: field.slug({ from: 'title', unique: true }),
1996
- excerpt: field.text({ maxLength: 500, translatable: true }),
1997
- body: field.richtext(),
1998
- viewCount: field.number({ default: 0, integer: true }),
1999
- featured: field.boolean({ default: false }),
2000
- publishDate: field.date(),
2001
- contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
2002
- category: field.reference({ entity: 'category', required: false }),
2003
- tags: field.reference({ entity: 'category', cardinality: 'many' }),
2004
- coverImage: field.media({ accept: ['image/*'] }),
2005
- },
2006
- behaviors: [
2007
- behavior.publishable(),
2008
- behavior.auditable(),
2009
- behavior.sluggable('title'),
2010
- behavior.revisionable(),
2011
- ],
2012
- scope: 'global',
2013
- access: {
2014
- view: 'public',
2015
- create: 'group.editor',
2016
- update: 'group.editor',
2017
- delete: 'group.admin',
2018
- },
2019
- })
2020
- `),await n(a(e,`entities/category.ts`),`import { defineEntity, field } from '@murumets-ee/entity'
2021
-
2022
- export const Category = defineEntity({
2023
- name: 'category',
2024
- fields: {
2025
- name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
2026
- slug: field.slug({ from: 'name', unique: true }),
2027
- description: field.text({ maxLength: 500, translatable: true }),
2028
- },
2029
- scope: 'global',
2030
- access: {
2031
- view: 'public',
2032
- create: 'group.editor',
2033
- update: 'group.editor',
2034
- delete: 'group.admin',
2035
- },
2036
- })
2037
- `)}async function d(e,t){await n(a(e,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
2038
-
2039
- export const routing = defineRouting({
2040
- locales: ['en'],
2041
- defaultLocale: 'en',
2042
- })
2043
- `),await n(a(e,`i18n/navigation.ts`),`import { createNavigation } from 'next-intl/navigation'
2044
- import { routing } from './routing'
2045
-
2046
- export const { Link, redirect, usePathname, useRouter, getPathname } = createNavigation(routing)
2047
- `),await n(a(e,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
2048
- import { hasLocale } from 'next-intl'
2049
- import { routing } from './routing'
2050
- import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
2051
-
2052
- export default getRequestConfig(async ({ requestLocale }) => {
2053
- const requested = await requestLocale
2054
- const locale = hasLocale(routing.locales, requested)
2055
- ? requested
2056
- : routing.defaultLocale
2057
-
2058
- const authMessages = await getAuthMessages(locale)
2059
-
2060
- return {
2061
- locale,
2062
- messages: {
2063
- ...authMessages,
2064
- },
2065
- }
2066
- })
2067
- `)}const f={myorgCore:`0.1.0`,myorgDb:`0.1.0`,myorgEntity:`0.1.0`,myorgLogging:`0.1.0`,myorgAuth:`0.1.0`,myorgAuthUi:`0.1.0`,myorgAdminUi:`0.1.0`,myorgContent:`0.1.0`,myorgContentApi:`0.1.0`,myorgSettings:`0.1.0`,myorgStorage:`0.1.0`,myorgMedia:`0.1.0`,myorgTaxonomy:`0.1.0`,myorgQueue:`0.1.0`,myorgMail:`0.1.0`,myorgTicketing:`0.1.0`,myorgTicketingUi:`0.1.0`,myorgEditor:`0.1.0`,myorgBlocks:`0.1.0`,myorgTokens:`0.1.0`,myorgUi:`0.1.0`,myorgCli:`0.1.0`,betterAuth:`1.4.0`,drizzleOrm:`0.45.1`,nextIntl:`4.8.2`,nextThemes:`0.4.6`,lucideReact:`0.563.0`,postgres:`3.4.5`,reactHookForm:`7.71.1`,hookformResolvers:`5.2.2`,zod:`3.24.1`,tanstackReactQuery:`5.60.5`,tanstackReactTable:`8.21.3`,drizzleKit:`0.31.10`,tsx:`4.19.2`,babelReactCompiler:`1.0.0`};async function p(e,t,n){await m(e,t),n?.(`Workspace root created`),n?.(`Creating admin app...`),await g(e,t),n?.(`Admin app created`),n?.(`Creating web app...`),await _(e,t),n?.(`Web app created`),await h(e,t),n?.(`Shared config package created`)}async function m(e,t){let{name:r}=t;await o(e,{recursive:!0}),await n(a(e,`pnpm-workspace.yaml`),`packages:
2068
- - 'packages/*'
2069
- - 'apps/*'
2070
-
2071
- ignoredBuiltDependencies:
2072
- - sharp
2073
- - unrs-resolver
2074
- `),await n(a(e,`package.json`),`${JSON.stringify({name:`@${r}/monorepo`,version:`0.0.0`,private:!0,type:`module`,scripts:{dev:`turbo dev`,build:`turbo build`,"db:generate":`tsx --env-file=.env packages/config/scripts/generate-schema.ts`,"db:migrate:generate":`drizzle-kit generate --config packages/config/drizzle.config.ts`,"db:migrate":`tsx --env-file=.env packages/config/scripts/migrate.ts`,"db:reset":`tsx --env-file=.env packages/config/scripts/reset-db.ts`},devDependencies:{turbo:`^2.3.3`,typescript:`^5.7.3`,tsx:`^${f.tsx}`,"drizzle-kit":`^${f.drizzleKit}`}},null,2)}\n`),await n(a(e,`.gitignore`),`# dependencies
2075
- node_modules/
2076
-
2077
- # build
2078
- dist/
2079
- .next/
2080
- .turbo/
2081
-
2082
- # env
2083
- .env*
2084
- !.env.example
2085
-
2086
- # toolkit generated
2087
- generated/
2088
-
2089
- # IDE
2090
- .vscode/
2091
- .idea/
2092
-
2093
- # OS
2094
- .DS_Store
2095
- Thumbs.db
2096
-
2097
- # logs
2098
- *.log
2099
-
2100
- # migrations meta
2101
- migrations/
2102
-
2103
- # turbo
2104
- .turbo/
2105
- `),await n(a(e,`.env.example`),`DATABASE_URL=postgresql://${r}:${r}_dev_password@localhost:5432/${r}_dev
2106
- BETTER_AUTH_SECRET=dev-secret-change-me-in-production-min-32-chars
2107
- BETTER_AUTH_URL=http://localhost:3000
2108
- LOG_LEVEL=debug
2109
- NEXT_PUBLIC_APP_URL=http://localhost:3000
2110
- QUEUE_WORKER=true
2111
- RESEND_API_KEY=
2112
- RESEND_WEBHOOK_SECRET=
2113
- MAIL_FROM=noreply@example.com
2114
- CSAT_SECRET=
2115
- `),await n(a(e,`docker-compose.yml`),`services:
2116
- postgres:
2117
- image: postgres:17-alpine
2118
- container_name: ${r}-postgres
2119
- restart: unless-stopped
2120
- environment:
2121
- POSTGRES_USER: ${r}
2122
- POSTGRES_PASSWORD: ${r}_dev_password
2123
- POSTGRES_DB: ${r}_dev
2124
- ports:
2125
- - "5432:5432"
2126
- volumes:
2127
- - postgres_data:/var/lib/postgresql/data
2128
- healthcheck:
2129
- test: ["CMD-SHELL", "pg_isready -U ${r} -d ${r}_dev"]
2130
- interval: 10s
2131
- timeout: 5s
2132
- retries: 5
2133
-
2134
- volumes:
2135
- postgres_data:
2136
- name: ${r}_postgres_data
2137
- `)}async function h(e,t){let{name:r}=t,i=a(e,`packages/config`);await n(a(i,`package.json`),`${JSON.stringify({name:`@${r}/config`,version:`0.1.0`,private:!0,type:`module`,exports:{".":`./toolkit.config.ts`,"./entities":`./entities/index.ts`,"./entities/*":`./entities/*`,"./app":`./lib/app.ts`,"./auth":`./lib/auth.ts`,"./auth-client":`./lib/auth-client.ts`},dependencies:{"@murumets-ee/core":`^${f.myorgCore}`,"@murumets-ee/db":`^${f.myorgDb}`,"@murumets-ee/entity":`^${f.myorgEntity}`,"@murumets-ee/logging":`^${f.myorgLogging}`,"@murumets-ee/auth":`^${f.myorgAuth}`,"better-auth":`^${f.betterAuth}`,"drizzle-orm":`^${f.drizzleOrm}`,postgres:`^${f.postgres}`,zod:`^${f.zod}`}},null,2)}\n`),await n(a(i,`tsconfig.json`),`${JSON.stringify({compilerOptions:{target:`ES2022`,module:`ESNext`,moduleResolution:`Bundler`,strict:!0,esModuleInterop:!0,skipLibCheck:!0,resolveJsonModule:!0},include:[`**/*.ts`]},null,2)}\n`),await n(a(i,`toolkit.config.ts`),`import { auth } from '@murumets-ee/auth/plugin'
2138
- import { content } from '@murumets-ee/content/plugin'
2139
- import { defineConfig } from '@murumets-ee/core'
2140
- import { logging } from '@murumets-ee/logging/plugin'
2141
- import { mail, ResendMailProvider } from '@murumets-ee/mail'
2142
- import { media } from '@murumets-ee/media/plugin'
2143
- import { queue } from '@murumets-ee/queue/plugin'
2144
- import { settings } from '@murumets-ee/settings/plugin'
2145
- import { storage } from '@murumets-ee/storage/plugin'
2146
- import { taxonomy } from '@murumets-ee/taxonomy/plugin'
2147
- import { ticketing } from '@murumets-ee/ticketing/plugin'
2148
- import { Article, Category } from './entities'
2149
- import * as authSchema from './generated/auth-schema'
2150
-
2151
- if (!process.env.DATABASE_URL) {
2152
- throw new Error('DATABASE_URL environment variable is required')
2153
- }
2154
-
2155
- export default defineConfig({
2156
- db: {
2157
- url: process.env.DATABASE_URL,
2158
- poolMin: 2,
2159
- poolMax: 10,
2160
- },
2161
- logging: {
2162
- level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
2163
- name: '${r}',
2164
- },
2165
- entities: [Category, Article],
2166
- plugins: [
2167
- auth({ providers: ['email'], schema: authSchema }),
2168
- content({
2169
- locales: [{ code: 'en', label: 'English' }],
2170
- defaultLocale: 'en',
2171
- }),
2172
- logging(),
2173
- settings(),
2174
- storage(),
2175
- media(),
2176
- taxonomy(),
2177
- queue(),
2178
- mail({
2179
- provider: process.env.RESEND_API_KEY
2180
- ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
2181
- : undefined,
2182
- defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
2183
- webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
2184
- }),
2185
- ticketing({
2186
- csatSecret: process.env.CSAT_SECRET,
2187
- }),
2188
- ],
2189
- projectRoot: import.meta.dirname,
2190
- })
2191
- `),await n(a(i,`auth.config.ts`),`import { betterAuth } from 'better-auth'
2192
- import { drizzleAdapter } from 'better-auth/adapters/drizzle'
2193
- import { admin } from 'better-auth/plugins'
2194
- import { organization } from 'better-auth/plugins/organization'
2195
- import { drizzle } from 'drizzle-orm/postgres-js'
2196
- import postgres from 'postgres'
2197
-
2198
- const sql = postgres(process.env.DATABASE_URL!)
2199
- const db = drizzle(sql)
2200
-
2201
- export const auth = betterAuth({
2202
- database: drizzleAdapter(db, { provider: 'pg' }),
2203
- emailAndPassword: { enabled: true },
2204
- plugins: [
2205
- admin(),
2206
- organization(),
2207
- ],
2208
- })
2209
- `),await n(a(i,`drizzle.config.ts`),`import type { Config } from 'drizzle-kit'
2210
-
2211
- if (!process.env.DATABASE_URL) {
2212
- throw new Error('DATABASE_URL environment variable is required')
2213
- }
2214
-
2215
- export default {
2216
- schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
2217
- out: './migrations',
2218
- dialect: 'postgresql',
2219
- dbCredentials: {
2220
- url: process.env.DATABASE_URL,
2221
- },
2222
- } satisfies Config
2223
- `),await n(a(i,`entities/index.ts`),`export { Article } from './article'
2224
- export { Category } from './category'
2225
- `),await n(a(i,`entities/article.ts`),`import { behavior, defineEntity, field } from '@murumets-ee/entity'
2226
-
2227
- export const Article = defineEntity({
2228
- name: 'article',
2229
- fields: {
2230
- title: field.text({ required: true, maxLength: 200, indexed: true, translatable: true }),
2231
- slug: field.slug({ from: 'title', unique: true }),
2232
- excerpt: field.text({ maxLength: 500, translatable: true }),
2233
- body: field.richtext(),
2234
- viewCount: field.number({ default: 0, integer: true }),
2235
- featured: field.boolean({ default: false }),
2236
- publishDate: field.date(),
2237
- contentType: field.select({ options: ['news', 'tutorial', 'announcement'], default: 'news' }),
2238
- category: field.reference({ entity: 'category', required: false }),
2239
- tags: field.reference({ entity: 'category', cardinality: 'many' }),
2240
- coverImage: field.media({ accept: ['image/*'] }),
2241
- },
2242
- behaviors: [
2243
- behavior.publishable(),
2244
- behavior.auditable(),
2245
- behavior.sluggable('title'),
2246
- behavior.revisionable(),
2247
- ],
2248
- scope: 'global',
2249
- access: {
2250
- view: 'public',
2251
- create: 'group.editor',
2252
- update: 'group.editor',
2253
- delete: 'group.admin',
2254
- },
2255
- })
2256
- `),await n(a(i,`entities/category.ts`),`import { defineEntity, field } from '@murumets-ee/entity'
2257
-
2258
- export const Category = defineEntity({
2259
- name: 'category',
2260
- fields: {
2261
- name: field.text({ required: true, maxLength: 100, indexed: true, translatable: true }),
2262
- slug: field.slug({ from: 'name', unique: true }),
2263
- description: field.text({ maxLength: 500, translatable: true }),
2264
- },
2265
- scope: 'global',
2266
- access: {
2267
- view: 'public',
2268
- create: 'group.editor',
2269
- update: 'group.editor',
2270
- delete: 'group.admin',
2271
- },
2272
- })
2273
- `),await n(a(i,`lib/app.ts`),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
2274
- import config from '../toolkit.config'
2275
-
2276
- let appInstance: ToolkitApp | null = null
2277
-
2278
- export async function getToolkitApp(): Promise<ToolkitApp> {
2279
- if (!appInstance) {
2280
- appInstance = await createApp(config)
2281
- setApp(appInstance)
2282
- }
2283
- return appInstance
2284
- }
2285
- `),await n(a(i,`lib/auth.ts`),`import { getAuth } from '@murumets-ee/auth'
2286
- import { getToolkitApp } from './app'
2287
-
2288
- await getToolkitApp()
2289
-
2290
- export const auth = getAuth()
2291
- `),await n(a(i,`lib/auth-client.ts`),`import { createClient } from '@murumets-ee/auth/client'
2292
-
2293
- export const authClient = createClient()
2294
- `),await n(a(i,`generated/auth-schema.ts`),`// This file is generated by better-auth CLI.
2295
- // Run: npx @better-auth/cli generate --config auth.config.ts -y
2296
- export {}
2297
- `),await n(a(i,`scripts/generate-schema.ts`),`/**
2298
- * Generate Drizzle schemas from entity definitions
2299
- */
2300
-
2301
- import { mkdir, writeFile } from 'node:fs/promises'
2302
- import { join } from 'node:path'
2303
- import { generateSchemaCode, generateTranslationSchemaCode } from '@murumets-ee/entity'
2304
- import config from '../toolkit.config'
2305
-
2306
- async function generateSchemas() {
2307
- console.log('Generating Drizzle schemas from entity definitions...')
2308
-
2309
- const schemaDir = join(import.meta.dirname, '..', 'generated')
2310
- await mkdir(schemaDir, { recursive: true })
2311
-
2312
- const imports: string[] = []
2313
- const schemas: string[] = []
2314
- let hasTranslations = false
2315
-
2316
- for (const entity of config.entities) {
2317
- console.log(\` - Generating schema for \${entity.name}\`)
2318
-
2319
- const schemaCode = generateSchemaCode(entity)
2320
- schemas.push(\`\\n// \${entity.name} table\`)
2321
- schemas.push(schemaCode)
2322
-
2323
- const translationCode = generateTranslationSchemaCode(entity)
2324
- if (translationCode) {
2325
- hasTranslations = true
2326
- schemas.push(\`\\n// \${entity.name} translations\`)
2327
- schemas.push(translationCode)
2328
- }
2329
- }
2330
-
2331
- const pgCoreTypes = ['pgTable', 'varchar', 'text', 'boolean', 'timestamp', 'integer', 'doublePrecision', 'jsonb', 'uuid', 'index']
2332
- if (hasTranslations) pgCoreTypes.push('unique')
2333
- const pgCoreImports = \`import { \${pgCoreTypes.join(', ')} } from 'drizzle-orm/pg-core'\\n\`
2334
- imports.push(pgCoreImports)
2335
-
2336
- const schemaFile = join(schemaDir, 'schema.ts')
2337
- await writeFile(schemaFile, [...imports, ...schemas].join('\\n'))
2338
-
2339
- console.log(\`\\nSchemas written to: \${schemaFile}\`)
2340
- console.log('Run \\\`pnpm db:migrate:generate\\\` to create migrations')
2341
- }
2342
-
2343
- generateSchemas().catch(console.error)
2344
- `),await n(a(i,`scripts/migrate.ts`),`/**
2345
- * Run pending migrations
2346
- */
2347
-
2348
- import { createDbClient, runMigrations } from '@murumets-ee/db'
2349
-
2350
- async function migrate() {
2351
- console.log('Running migrations...')
2352
-
2353
- try {
2354
- if (!process.env.DATABASE_URL) {
2355
- throw new Error('DATABASE_URL environment variable is required')
2356
- }
2357
-
2358
- const db = createDbClient({ url: process.env.DATABASE_URL })
2359
- await runMigrations(db, import.meta.dirname + '/..')
2360
-
2361
- console.log('Migrations completed successfully')
2362
- process.exit(0)
2363
- } catch (error) {
2364
- console.error('Migration failed:', error)
2365
- process.exit(1)
2366
- }
2367
- }
2368
-
2369
- migrate()
2370
- `),await n(a(i,`scripts/reset-db.ts`),`/**
2371
- * Reset database (DROP all tables)
2372
- * WARNING: Only for development
2373
- */
2374
-
2375
- import postgres from 'postgres'
2376
-
2377
- async function resetDb() {
2378
- const DATABASE_URL = process.env.DATABASE_URL
2379
-
2380
- if (!DATABASE_URL) {
2381
- throw new Error('DATABASE_URL not set')
2382
- }
2383
-
2384
- console.warn('WARNING: This will DROP ALL TABLES')
2385
- console.log('Database:', DATABASE_URL.split('@')[1])
2386
-
2387
- const sql = postgres(DATABASE_URL)
2388
-
2389
- try {
2390
- await sql\`
2391
- DROP SCHEMA public CASCADE;
2392
- CREATE SCHEMA public;
2393
- GRANT ALL ON SCHEMA public TO PUBLIC;
2394
- \`
2395
-
2396
- console.log('Database reset complete')
2397
- } finally {
2398
- await sql.end()
2399
- }
2400
- }
2401
-
2402
- resetDb().catch(console.error)
2403
- `)}async function g(i,s){let{name:c}=s,l=a(i,`apps/admin`);await o(a(i,`apps`),{recursive:!0}),e(`pnpm create next-app@16 ${l} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await t(l,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await r(a(l,`package.json`),{dependencies:{[`@${c}/config`]:`workspace:*`,"@murumets-ee/auth-ui":`^${f.myorgAuthUi}`,"better-auth":`^${f.betterAuth}`,"next-intl":`^${f.nextIntl}`,"next-themes":`^${f.nextThemes}`,"lucide-react":`^${f.lucideReact}`,"react-hook-form":`^${f.reactHookForm}`,"@hookform/resolvers":`^${f.hookformResolvers}`,zod:`^${f.zod}`},devDependencies:{"babel-plugin-react-compiler":f.babelReactCompiler}});let{appendToFile:u}=await import(`./utils-Bmw75lEa.mjs`).then(e=>e.a);await u(a(l,`.gitignore`),`
2404
- # Environment
2405
- .env*
2406
- !.env.example
2407
- `),await n(a(l,`next.config.ts`),`import type { NextConfig } from 'next'
2408
- import createNextIntlPlugin from 'next-intl/plugin'
2409
-
2410
- const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
2411
-
2412
- const nextConfig: NextConfig = {
2413
- reactCompiler: true,
2414
- transpilePackages: [
2415
- '@${c}/config',
2416
- '@murumets-ee/core',
2417
- '@murumets-ee/entity',
2418
- '@murumets-ee/db',
2419
- '@murumets-ee/logging',
2420
- '@murumets-ee/auth-ui',
2421
- ],
2422
- serverExternalPackages: ['drizzle-orm', 'postgres'],
2423
- experimental: {
2424
- serverActions: {
2425
- bodySizeLimit: '2mb',
2426
- },
2427
- },
2428
- }
2429
-
2430
- export default withNextIntl(nextConfig)
2431
- `),await n(a(l,`proxy.ts`),`import { NextRequest, NextResponse } from 'next/server'
2432
- import { getSessionCookie } from 'better-auth/cookies'
2433
- import createMiddleware from 'next-intl/middleware'
2434
- import { routing } from './i18n/routing'
2435
-
2436
- const intlMiddleware = createMiddleware(routing)
2437
-
2438
- const protectedPaths = ['/setup']
2439
-
2440
- export function proxy(request: NextRequest) {
2441
- const { pathname } = request.nextUrl
2442
-
2443
- const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
2444
- const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
2445
-
2446
- if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
2447
- const session = getSessionCookie(request)
2448
- if (!session) {
2449
- const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
2450
- return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
2451
- }
2452
- }
2453
-
2454
- return intlMiddleware(request)
2455
- }
2456
-
2457
- export const config = {
2458
- matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
2459
- }
2460
- `),await n(a(l,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
2461
-
2462
- export const routing = defineRouting({
2463
- locales: ['en'],
2464
- defaultLocale: 'en',
2465
- })
2466
- `),await n(a(l,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
2467
- import { hasLocale } from 'next-intl'
2468
- import { routing } from './routing'
2469
- import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
2470
-
2471
- export default getRequestConfig(async ({ requestLocale }) => {
2472
- const requested = await requestLocale
2473
- const locale = hasLocale(routing.locales, requested)
2474
- ? requested
2475
- : routing.defaultLocale
2476
-
2477
- const authMessages = await getAuthMessages(locale)
2478
-
2479
- return {
2480
- locale,
2481
- messages: {
2482
- ...authMessages,
2483
- },
2484
- }
2485
- })
2486
- `),await n(a(l,`app/globals.css`),`@import "tailwindcss";
2487
- @source "../node_modules/@murumets-ee/auth-ui/dist";
2488
-
2489
- @custom-variant dark (&:where(.dark, .dark *));
2490
-
2491
- :root {
2492
- --background: #ffffff;
2493
- --foreground: #171717;
2494
- }
2495
-
2496
- @theme inline {
2497
- --color-background: var(--background);
2498
- --color-foreground: var(--foreground);
2499
- --font-sans: var(--font-geist-sans);
2500
- --font-mono: var(--font-geist-mono);
2501
- }
2502
-
2503
- .dark {
2504
- --background: #0a0a0a;
2505
- --foreground: #ededed;
2506
- }
2507
-
2508
- body {
2509
- background: var(--background);
2510
- color: var(--foreground);
2511
- font-family: Arial, Helvetica, sans-serif;
2512
- }
2513
- `),await n(a(l,`app/theme-provider.tsx`),`'use client'
2514
-
2515
- import { ThemeProvider as NextThemesProvider } from 'next-themes'
2516
- import type { ReactNode } from 'react'
2517
-
2518
- export function ThemeProvider({ children }: { children: ReactNode }) {
2519
- return (
2520
- <NextThemesProvider
2521
- attribute="class"
2522
- defaultTheme="system"
2523
- enableSystem
2524
- disableTransitionOnChange
2525
- >
2526
- {children}
2527
- </NextThemesProvider>
2528
- )
2529
- }
2530
- `),await n(a(l,`app/theme-toggle.tsx`),`'use client'
2531
-
2532
- import { useTheme } from 'next-themes'
2533
- import { useState, useEffect } from 'react'
2534
- import { Sun, Moon } from 'lucide-react'
2535
-
2536
- export function ThemeToggle() {
2537
- const [mounted, setMounted] = useState(false)
2538
- const { resolvedTheme, setTheme } = useTheme()
2539
-
2540
- useEffect(() => { setMounted(true) }, [])
2541
-
2542
- if (!mounted) {
2543
- return <div className="h-8 w-8" />
2544
- }
2545
-
2546
- return (
2547
- <button
2548
- type="button"
2549
- onClick={() => setTheme(resolvedTheme === 'dark' ? 'light' : 'dark')}
2550
- className="rounded-md p-1.5 text-zinc-400 transition-colors hover:bg-zinc-100 hover:text-zinc-600 dark:hover:bg-zinc-800 dark:hover:text-zinc-200 cursor-pointer"
2551
- aria-label={resolvedTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
2552
- >
2553
- {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
2554
- </button>
2555
- )
2556
- }
2557
- `),await n(a(l,`app/nav-header.tsx`),`'use client'
2558
-
2559
- import Link from 'next/link'
2560
- import { usePathname } from 'next/navigation'
2561
- import { ThemeToggle } from './theme-toggle'
2562
-
2563
- const links = [
2564
- { href: '/', label: 'Home' },
2565
- { href: '/auth/sign-in', label: 'Sign In' },
2566
- { href: '/setup', label: 'Setup' },
2567
- ]
2568
-
2569
- export function NavHeader() {
2570
- const pathname = usePathname()
2571
-
2572
- function isActive(href: string) {
2573
- return href === '/' ? pathname === '/' : pathname.startsWith(href)
2574
- }
2575
-
2576
- const linkClass = (href: string) =>
2577
- \`px-3 py-1.5 rounded-md text-sm transition-colors \${
2578
- isActive(href)
2579
- ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
2580
- : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
2581
- }\`
2582
-
2583
- return (
2584
- <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
2585
- <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
2586
- <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
2587
- ${c} Admin
2588
- </span>
2589
- {links.map(({ href, label }) => (
2590
- <Link key={href} href={href} className={linkClass(href)}>
2591
- {label}
2592
- </Link>
2593
- ))}
2594
- <div className="ml-auto">
2595
- <ThemeToggle />
2596
- </div>
2597
- </nav>
2598
- </header>
2599
- )
2600
- }
2601
- `),await n(a(l,`app/layout.tsx`),`import './globals.css'
2602
-
2603
- export default function RootLayout({ children }: { children: React.ReactNode }) {
2604
- return children
2605
- }
2606
- `),await n(a(l,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
2607
- import { Geist, Geist_Mono } from 'next/font/google'
2608
- import { NextIntlClientProvider } from 'next-intl'
2609
- import { getMessages } from 'next-intl/server'
2610
- import { notFound } from 'next/navigation'
2611
- import { routing } from '@/i18n/routing'
2612
- import { ThemeProvider } from '../theme-provider'
2613
- import { NavHeader } from '../nav-header'
2614
-
2615
- const geistSans = Geist({
2616
- variable: '--font-geist-sans',
2617
- subsets: ['latin'],
2618
- })
2619
-
2620
- const geistMono = Geist_Mono({
2621
- variable: '--font-geist-mono',
2622
- subsets: ['latin'],
2623
- })
2624
-
2625
- export const metadata: Metadata = {
2626
- title: '${c} Admin',
2627
- description: 'Built with Lumi CMS Toolkit',
2628
- }
2629
-
2630
- export default async function LocaleLayout({
2631
- children,
2632
- params,
2633
- }: {
2634
- children: React.ReactNode
2635
- params: Promise<{ locale: string }>
2636
- }) {
2637
- const { locale } = await params
2638
- if (!routing.locales.includes(locale as any)) notFound()
2639
-
2640
- const messages = await getMessages()
2641
-
2642
- return (
2643
- <html lang={locale} suppressHydrationWarning>
2644
- <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
2645
- <ThemeProvider>
2646
- <NextIntlClientProvider locale={locale} messages={messages}>
2647
- <NavHeader />
2648
- {children}
2649
- </NextIntlClientProvider>
2650
- </ThemeProvider>
2651
- </body>
2652
- </html>
2653
- )
2654
- }
2655
- `),await n(a(l,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
2656
- import { Article, Category } from '@${c}/config/entities'
2657
- import { getToolkitApp } from '@${c}/config/app'
2658
-
2659
- export default async function HomePage() {
2660
- await getToolkitApp()
2661
- const articles = createQueryClient(Article)
2662
- const categories = createQueryClient(Category)
2663
-
2664
- const [articleList, categoryList] = await Promise.all([
2665
- articles.findMany({ limit: 10 }),
2666
- categories.findMany({}),
2667
- ])
2668
-
2669
- return (
2670
- <div className="min-h-screen p-8">
2671
- <div className="max-w-4xl mx-auto">
2672
- <h1 className="text-4xl font-bold mb-8">Welcome to ${c}</h1>
2673
-
2674
- <section className="mb-12">
2675
- <h2 className="text-2xl font-semibold mb-4">
2676
- Categories ({categoryList.length})
2677
- </h2>
2678
- <div className="grid gap-4 md:grid-cols-2">
2679
- {categoryList.map((cat) => (
2680
- <div
2681
- key={cat.id}
2682
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
2683
- >
2684
- <h3 className="font-semibold text-lg">{cat.name}</h3>
2685
- <p className="text-sm text-zinc-600 dark:text-zinc-400">
2686
- {cat.description}
2687
- </p>
2688
- </div>
2689
- ))}
2690
- </div>
2691
- </section>
2692
-
2693
- <section>
2694
- <h2 className="text-2xl font-semibold mb-4">
2695
- Articles ({articleList.length})
2696
- </h2>
2697
- {articleList.length === 0 ? (
2698
- <p className="text-zinc-500">No published articles yet.</p>
2699
- ) : (
2700
- <div className="space-y-4">
2701
- {articleList.map((article) => (
2702
- <div
2703
- key={article.id}
2704
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
2705
- >
2706
- <h3 className="text-xl font-bold">{article.title}</h3>
2707
- <p className="text-zinc-600 dark:text-zinc-400 mt-2">
2708
- {article.excerpt}
2709
- </p>
2710
- </div>
2711
- ))}
2712
- </div>
2713
- )}
2714
- </section>
2715
- </div>
2716
- </div>
2717
- )
2718
- }
2719
- `),await n(a(l,`app/api/auth/[...all]/route.ts`),`import { toNextJsHandler } from 'better-auth/next-js'
2720
- import { auth } from '@${c}/config/auth'
2721
-
2722
- const { GET, POST } = toNextJsHandler(auth)
2723
- export { GET, POST }
2724
- `),await n(a(l,`app/[locale]/auth/layout.tsx`),`import { AuthProviders } from './providers'
2725
- import type { ReactNode } from 'react'
2726
-
2727
- export default function AuthLayout({ children }: { children: ReactNode }) {
2728
- return (
2729
- <AuthProviders>
2730
- <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
2731
- {children}
2732
- </div>
2733
- </AuthProviders>
2734
- )
2735
- }
2736
- `),await n(a(l,`app/[locale]/auth/providers.tsx`),`'use client'
2737
-
2738
- import { AuthUIProvider } from '@murumets-ee/auth-ui'
2739
- import { authClient } from '@${c}/config/auth-client'
2740
- import Link from 'next/link'
2741
- import { useRouter } from 'next/navigation'
2742
- import { useLocale } from 'next-intl'
2743
- import type { ReactNode } from 'react'
2744
-
2745
- export function AuthProviders({ children }: { children: ReactNode }) {
2746
- const router = useRouter()
2747
- const locale = useLocale()
2748
-
2749
- return (
2750
- <AuthUIProvider
2751
- authClient={authClient as never}
2752
- basePath="/auth"
2753
- redirectTo="/"
2754
- Link={Link}
2755
- navigate={(url) => router.push(url)}
2756
- resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
2757
- >
2758
- {children}
2759
- </AuthUIProvider>
2760
- )
2761
- }
2762
- `),await n(a(l,`app/[locale]/auth/sign-in/page.tsx`),`import { SignInForm } from '@murumets-ee/auth-ui'
2763
-
2764
- export default function SignInPage() {
2765
- return <SignInForm />
2766
- }
2767
- `),await n(a(l,`app/[locale]/auth/sign-up/page.tsx`),`import { SignUpForm } from '@murumets-ee/auth-ui'
2768
-
2769
- export default function SignUpPage() {
2770
- return <SignUpForm />
2771
- }
2772
- `),await n(a(l,`app/[locale]/auth/forgot-password/page.tsx`),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
2773
-
2774
- export default function ForgotPasswordPage() {
2775
- return <ForgotPasswordForm />
2776
- }
2777
- `),await n(a(l,`app/[locale]/auth/reset-password/page.tsx`),`import { Suspense } from 'react'
2778
- import { ResetPasswordContent } from './content'
2779
-
2780
- export default function ResetPasswordPage() {
2781
- return (
2782
- <Suspense>
2783
- <ResetPasswordContent />
2784
- </Suspense>
2785
- )
2786
- }
2787
- `),await n(a(l,`app/[locale]/auth/reset-password/content.tsx`),`'use client'
2788
-
2789
- import { useSearchParams } from 'next/navigation'
2790
- import { ResetPasswordForm } from '@murumets-ee/auth-ui'
2791
-
2792
- export function ResetPasswordContent() {
2793
- const searchParams = useSearchParams()
2794
- const token = searchParams.get('token')
2795
-
2796
- if (!token) {
2797
- return (
2798
- <div className="text-center">
2799
- <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
2800
- Invalid or expired link
2801
- </h1>
2802
- <p className="mt-2 text-zinc-500">
2803
- Please request a new password reset.
2804
- </p>
2805
- </div>
2806
- )
2807
- }
2808
-
2809
- return <ResetPasswordForm token={token} />
2810
- }
2811
- `),await n(a(l,`app/[locale]/setup/page.tsx`),`import { redirect } from 'next/navigation'
2812
- import { sql } from 'drizzle-orm'
2813
- import { getToolkitApp } from '@${c}/config/app'
2814
- import { SetupForm } from './form'
2815
-
2816
- export default async function SetupPage() {
2817
- const app = await getToolkitApp()
2818
- const result = await app.db.readOnly.execute<{ count: string }>(
2819
- sql\`SELECT COUNT(*)::text as count FROM "user"\`,
2820
- )
2821
-
2822
- if (Number(result[0]?.count) > 0) {
2823
- redirect('/')
2824
- }
2825
-
2826
- return (
2827
- <div className="max-w-md mx-auto p-8">
2828
- <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
2829
- <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
2830
- No users found. Create the first admin account.
2831
- </p>
2832
- <SetupForm />
2833
- </div>
2834
- )
2835
- }
2836
- `),await n(a(l,`app/[locale]/setup/form.tsx`),`'use client'
2837
-
2838
- import { useState } from 'react'
2839
- import { createFirstAdmin } from './actions'
2840
-
2841
- export function SetupForm() {
2842
- const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
2843
- const [loading, setLoading] = useState(false)
2844
-
2845
- async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
2846
- e.preventDefault()
2847
- setLoading(true)
2848
- setResult(null)
2849
- const res = await createFirstAdmin(new FormData(e.currentTarget))
2850
- setResult(res)
2851
- setLoading(false)
2852
- }
2853
-
2854
- if (result?.ok) {
2855
- return (
2856
- <div className="space-y-2">
2857
- <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
2858
- <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
2859
- Sign in &rarr;
2860
- </a>
2861
- </div>
2862
- )
2863
- }
2864
-
2865
- return (
2866
- <form onSubmit={handleSubmit} className="flex flex-col gap-4">
2867
- <label className="text-sm font-medium">
2868
- Name
2869
- <input
2870
- name="name"
2871
- required
2872
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
2873
- />
2874
- </label>
2875
- <label className="text-sm font-medium">
2876
- Email
2877
- <input
2878
- name="email"
2879
- type="email"
2880
- required
2881
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
2882
- />
2883
- </label>
2884
- <label className="text-sm font-medium">
2885
- Password (min 8 chars)
2886
- <input
2887
- name="password"
2888
- type="password"
2889
- required
2890
- minLength={8}
2891
- className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
2892
- />
2893
- </label>
2894
-
2895
- {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
2896
-
2897
- <button
2898
- type="submit"
2899
- disabled={loading}
2900
- className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
2901
- >
2902
- {loading ? 'Creating...' : 'Create Admin'}
2903
- </button>
2904
- </form>
2905
- )
2906
- }
2907
- `),await n(a(l,`app/[locale]/setup/actions.ts`),`'use server'
2908
-
2909
- import { sql } from 'drizzle-orm'
2910
- import { getToolkitApp } from '@${c}/config/app'
2911
- import { getAuth } from '@murumets-ee/auth'
2912
-
2913
- export async function createFirstAdmin(formData: FormData) {
2914
- const email = formData.get('email') as string
2915
- const password = formData.get('password') as string
2916
- const name = formData.get('name') as string
287
+ try {
288
+ if (!process.env.DATABASE_URL) {
289
+ throw new Error('DATABASE_URL environment variable is required')
290
+ }
2917
291
 
2918
- if (!email || !password || !name) {
2919
- return { error: 'All fields are required' }
2920
- }
292
+ const db = createDbClient({ url: process.env.DATABASE_URL })
293
+ await runMigrations(db, import.meta.dirname + '/..')
2921
294
 
2922
- if (password.length < 8) {
2923
- return { error: 'Password must be at least 8 characters' }
295
+ console.log('Migrations completed successfully')
296
+ process.exit(0)
297
+ } catch (error) {
298
+ console.error('Migration failed:', error)
299
+ process.exit(1)
2924
300
  }
301
+ }
2925
302
 
2926
- const app = await getToolkitApp()
303
+ migrate()
304
+ `),await e(d(i,`scripts/reset-db.ts`),`/**
305
+ * Reset database (DROP all tables)
306
+ * WARNING: Only for development
307
+ */
2927
308
 
2928
- const canCreate = await app.db.readWrite.transaction(async (tx) => {
2929
- await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
309
+ import postgres from 'postgres'
2930
310
 
2931
- const result = await tx.execute<{ count: string }>(
2932
- sql\`SELECT COUNT(*)::text as count FROM "user"\`,
2933
- )
2934
- return Number(result[0]?.count) === 0
2935
- })
311
+ async function resetDb() {
312
+ const DATABASE_URL = process.env.DATABASE_URL
2936
313
 
2937
- if (!canCreate) {
2938
- return { error: 'Admin user already exists. Setup is complete.' }
314
+ if (!DATABASE_URL) {
315
+ throw new Error('DATABASE_URL not set')
2939
316
  }
2940
317
 
2941
- const auth = getAuth()
318
+ console.warn('WARNING: This will DROP ALL TABLES')
319
+ console.log('Database:', DATABASE_URL.split('@')[1])
2942
320
 
2943
- const adminUser = await auth.api.signUpEmail({
2944
- body: { email, password, name },
2945
- })
321
+ const sql = postgres(DATABASE_URL)
2946
322
 
2947
- await app.db.readWrite.execute(
2948
- sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
2949
- )
323
+ try {
324
+ await sql\`
325
+ DROP SCHEMA public CASCADE;
326
+ CREATE SCHEMA public;
327
+ GRANT ALL ON SCHEMA public TO PUBLIC;
328
+ \`
2950
329
 
2951
- return { ok: true, email: adminUser.user.email }
330
+ console.log('Database reset complete')
331
+ } finally {
332
+ await sql.end()
333
+ }
2952
334
  }
2953
- `)}async function _(i,o){let{name:s}=o,c=a(i,`apps/web`);e(`pnpm create next-app@16 ${c} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await t(c,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await r(a(c,`package.json`),{dependencies:{[`@${s}/config`]:`workspace:*`,"@murumets-ee/core":`^${f.myorgCore}`,"@murumets-ee/auth-ui":`^${f.myorgAuthUi}`,"next-intl":`^${f.nextIntl}`,"next-themes":`^${f.nextThemes}`,"lucide-react":`^${f.lucideReact}`},devDependencies:{"babel-plugin-react-compiler":f.babelReactCompiler}});let{appendToFile:l}=await import(`./utils-Bmw75lEa.mjs`).then(e=>e.a);await l(a(c,`.gitignore`),`
335
+
336
+ resetDb().catch(console.error)
337
+ `)}async function v(i,a){let{name:s}=a,c=d(i,`apps/admin`);await o(d(i,`apps`),{recursive:!0}),n(`pnpm create next-app@16 ${c} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await r(c,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await t(d(c,`package.json`),{dependencies:{[`@${s}/config`]:`workspace:*`,"@murumets-ee/auth-ui":`^${m.myorgAuthUi}`,"better-auth":`^${m.betterAuth}`,"next-intl":`^${m.nextIntl}`,"next-themes":`^${m.nextThemes}`,"lucide-react":`^${m.lucideReact}`,"react-hook-form":`^${m.reactHookForm}`,"@hookform/resolvers":`^${m.hookformResolvers}`,zod:`^${m.zod}`},devDependencies:{"babel-plugin-react-compiler":m.babelReactCompiler}});let{appendToFile:l}=await import(`./utils-DHetVfSn.mjs`).then(e=>e.i);await l(d(c,`.gitignore`),`
2954
338
  # Environment
2955
339
  .env*
2956
340
  !.env.example
2957
- `),await n(a(c,`next.config.ts`),`import type { NextConfig } from 'next'
341
+ `),await e(d(c,`next.config.ts`),`import type { NextConfig } from 'next'
2958
342
  import createNextIntlPlugin from 'next-intl/plugin'
2959
343
 
2960
344
  const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
@@ -2970,24 +354,50 @@ const nextConfig: NextConfig = {
2970
354
  '@murumets-ee/auth-ui',
2971
355
  ],
2972
356
  serverExternalPackages: ['drizzle-orm', 'postgres'],
357
+ experimental: {
358
+ serverActions: {
359
+ bodySizeLimit: '2mb',
360
+ },
361
+ },
2973
362
  }
2974
363
 
2975
364
  export default withNextIntl(nextConfig)
2976
- `),await n(a(c,`proxy.ts`),`import createMiddleware from 'next-intl/middleware'
365
+ `),await e(d(c,`proxy.ts`),`import { NextRequest, NextResponse } from 'next/server'
366
+ import { getSessionCookie } from 'better-auth/cookies'
367
+ import createMiddleware from 'next-intl/middleware'
2977
368
  import { routing } from './i18n/routing'
2978
369
 
2979
- export default createMiddleware(routing)
370
+ const intlMiddleware = createMiddleware(routing)
371
+
372
+ const protectedPaths = ['/setup']
373
+
374
+ export function proxy(request: NextRequest) {
375
+ const { pathname } = request.nextUrl
376
+
377
+ const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
378
+ const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
379
+
380
+ if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
381
+ const session = getSessionCookie(request)
382
+ if (!session) {
383
+ const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
384
+ return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
385
+ }
386
+ }
387
+
388
+ return intlMiddleware(request)
389
+ }
2980
390
 
2981
391
  export const config = {
2982
392
  matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
2983
393
  }
2984
- `),await n(a(c,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
394
+ `),await e(d(c,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
2985
395
 
2986
396
  export const routing = defineRouting({
2987
397
  locales: ['en'],
2988
398
  defaultLocale: 'en',
2989
399
  })
2990
- `),await n(a(c,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
400
+ `),await e(d(c,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
2991
401
  import { hasLocale } from 'next-intl'
2992
402
  import { routing } from './routing'
2993
403
  import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
@@ -3007,7 +417,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
3007
417
  },
3008
418
  }
3009
419
  })
3010
- `),await n(a(c,`app/globals.css`),`@import "tailwindcss";
420
+ `),await e(d(c,`app/globals.css`),`@import "tailwindcss";
3011
421
  @source "../node_modules/@murumets-ee/auth-ui/dist";
3012
422
 
3013
423
  @custom-variant dark (&:where(.dark, .dark *));
@@ -3034,7 +444,7 @@ body {
3034
444
  color: var(--foreground);
3035
445
  font-family: Arial, Helvetica, sans-serif;
3036
446
  }
3037
- `),await n(a(c,`app/theme-provider.tsx`),`'use client'
447
+ `),await e(d(c,`app/theme-provider.tsx`),`'use client'
3038
448
 
3039
449
  import { ThemeProvider as NextThemesProvider } from 'next-themes'
3040
450
  import type { ReactNode } from 'react'
@@ -3051,7 +461,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
3051
461
  </NextThemesProvider>
3052
462
  )
3053
463
  }
3054
- `),await n(a(c,`app/theme-toggle.tsx`),`'use client'
464
+ `),await e(d(c,`app/theme-toggle.tsx`),`'use client'
3055
465
 
3056
466
  import { useTheme } from 'next-themes'
3057
467
  import { useState, useEffect } from 'react'
@@ -3076,231 +486,58 @@ export function ThemeToggle() {
3076
486
  >
3077
487
  {resolvedTheme === 'dark' ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
3078
488
  </button>
3079
- )
3080
- }
3081
- `),await n(a(c,`app/nav-header.tsx`),`'use client'
3082
-
3083
- import Link from 'next/link'
3084
- import { usePathname } from 'next/navigation'
3085
- import { ThemeToggle } from './theme-toggle'
3086
-
3087
- const links = [
3088
- { href: '/', label: 'Home' },
3089
- ]
3090
-
3091
- export function NavHeader() {
3092
- const pathname = usePathname()
3093
-
3094
- function isActive(href: string) {
3095
- return href === '/' ? pathname === '/' : pathname.startsWith(href)
3096
- }
3097
-
3098
- const linkClass = (href: string) =>
3099
- \`px-3 py-1.5 rounded-md text-sm transition-colors \${
3100
- isActive(href)
3101
- ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
3102
- : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
3103
- }\`
3104
-
3105
- return (
3106
- <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
3107
- <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
3108
- <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
3109
- ${s}
3110
- </span>
3111
- {links.map(({ href, label }) => (
3112
- <Link key={href} href={href} className={linkClass(href)}>
3113
- {label}
3114
- </Link>
3115
- ))}
3116
- <div className="ml-auto">
3117
- <ThemeToggle />
3118
- </div>
3119
- </nav>
3120
- </header>
3121
- )
3122
- }
3123
- `),await n(a(c,`app/layout.tsx`),`import './globals.css'
3124
-
3125
- export default function RootLayout({ children }: { children: React.ReactNode }) {
3126
- return children
3127
- }
3128
- `),await n(a(c,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
3129
- import { Geist, Geist_Mono } from 'next/font/google'
3130
- import { NextIntlClientProvider } from 'next-intl'
3131
- import { getMessages } from 'next-intl/server'
3132
- import { notFound } from 'next/navigation'
3133
- import { routing } from '@/i18n/routing'
3134
- import { ThemeProvider } from '../theme-provider'
3135
- import { NavHeader } from '../nav-header'
3136
-
3137
- const geistSans = Geist({
3138
- variable: '--font-geist-sans',
3139
- subsets: ['latin'],
3140
- })
3141
-
3142
- const geistMono = Geist_Mono({
3143
- variable: '--font-geist-mono',
3144
- subsets: ['latin'],
3145
- })
3146
-
3147
- export const metadata: Metadata = {
3148
- title: '${s}',
3149
- description: 'Built with Lumi CMS Toolkit',
3150
- }
3151
-
3152
- export default async function LocaleLayout({
3153
- children,
3154
- params,
3155
- }: {
3156
- children: React.ReactNode
3157
- params: Promise<{ locale: string }>
3158
- }) {
3159
- const { locale } = await params
3160
- if (!routing.locales.includes(locale as any)) notFound()
3161
-
3162
- const messages = await getMessages()
3163
-
3164
- return (
3165
- <html lang={locale} suppressHydrationWarning>
3166
- <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
3167
- <ThemeProvider>
3168
- <NextIntlClientProvider locale={locale} messages={messages}>
3169
- <NavHeader />
3170
- {children}
3171
- </NextIntlClientProvider>
3172
- </ThemeProvider>
3173
- </body>
3174
- </html>
3175
- )
3176
- }
3177
- `),await n(a(c,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
3178
- import { Article, Category } from '@${s}/config/entities'
3179
- import { getToolkitApp } from '@${s}/config/app'
3180
-
3181
- export default async function HomePage() {
3182
- await getToolkitApp()
3183
- const articles = createQueryClient(Article)
3184
- const categories = createQueryClient(Category)
3185
-
3186
- const [articleList, categoryList] = await Promise.all([
3187
- articles.findMany({ limit: 10 }),
3188
- categories.findMany({}),
3189
- ])
3190
-
3191
- return (
3192
- <div className="min-h-screen p-8">
3193
- <div className="max-w-4xl mx-auto">
3194
- <h1 className="text-4xl font-bold mb-8">Welcome to ${s}</h1>
3195
-
3196
- <section className="mb-12">
3197
- <h2 className="text-2xl font-semibold mb-4">
3198
- Categories ({categoryList.length})
3199
- </h2>
3200
- <div className="grid gap-4 md:grid-cols-2">
3201
- {categoryList.map((cat) => (
3202
- <div
3203
- key={cat.id}
3204
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
3205
- >
3206
- <h3 className="font-semibold text-lg">{cat.name}</h3>
3207
- <p className="text-sm text-zinc-600 dark:text-zinc-400">
3208
- {cat.description}
3209
- </p>
3210
- </div>
3211
- ))}
3212
- </div>
3213
- </section>
3214
-
3215
- <section>
3216
- <h2 className="text-2xl font-semibold mb-4">
3217
- Articles ({articleList.length})
3218
- </h2>
3219
- {articleList.length === 0 ? (
3220
- <p className="text-zinc-500">No published articles yet.</p>
3221
- ) : (
3222
- <div className="space-y-4">
3223
- {articleList.map((article) => (
3224
- <div
3225
- key={article.id}
3226
- className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
3227
- >
3228
- <h3 className="text-xl font-bold">{article.title}</h3>
3229
- <p className="text-zinc-600 dark:text-zinc-400 mt-2">
3230
- {article.excerpt}
3231
- </p>
3232
- </div>
3233
- ))}
3234
- </div>
3235
- )}
3236
- </section>
3237
- </div>
3238
- </div>
3239
- )
3240
- }
3241
- `)}async function v(e,t){let{name:r}=t;await n(a(e,`next.config.ts`),`import type { NextConfig } from 'next'
3242
- import createNextIntlPlugin from 'next-intl/plugin'
3243
-
3244
- const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
3245
-
3246
- const nextConfig: NextConfig = {
3247
- output: 'standalone',
3248
- reactCompiler: true,
3249
- transpilePackages: [
3250
- '@murumets-ee/core', '@murumets-ee/entity', '@murumets-ee/db',
3251
- '@murumets-ee/logging', '@murumets-ee/auth', '@murumets-ee/auth-ui',
3252
- '@murumets-ee/admin-ui', '@murumets-ee/content', '@murumets-ee/settings',
3253
- '@murumets-ee/storage', '@murumets-ee/media', '@murumets-ee/taxonomy',
3254
- '@murumets-ee/queue', '@murumets-ee/mail', '@murumets-ee/ticketing',
3255
- '@murumets-ee/ticketing-ui', '@murumets-ee/tokens', '@murumets-ee/ui',
3256
- ],
3257
- serverExternalPackages: ['drizzle-orm', 'postgres', 'pino', 'file-type'],
3258
- experimental: {
3259
- serverActions: {
3260
- bodySizeLimit: '2mb',
3261
- },
3262
- },
489
+ )
3263
490
  }
491
+ `),await e(d(c,`app/nav-header.tsx`),`'use client'
3264
492
 
3265
- export default withNextIntl(nextConfig)
3266
- `),await n(a(e,`proxy.ts`),`import { NextRequest, NextResponse } from 'next/server'
3267
- import { getSessionCookie } from 'better-auth/cookies'
3268
- import createMiddleware from 'next-intl/middleware'
3269
- import { routing } from './i18n/routing'
3270
-
3271
- const intlMiddleware = createMiddleware(routing)
3272
-
3273
- const protectedPaths = ['/setup']
493
+ import Link from 'next/link'
494
+ import { usePathname } from 'next/navigation'
495
+ import { ThemeToggle } from './theme-toggle'
3274
496
 
3275
- export function proxy(request: NextRequest) {
3276
- const { pathname } = request.nextUrl
497
+ const links = [
498
+ { href: '/', label: 'Home' },
499
+ { href: '/auth/sign-in', label: 'Sign In' },
500
+ { href: '/setup', label: 'Setup' },
501
+ ]
3277
502
 
3278
- // Strip locale prefix to check actual path
3279
- const localePattern = new RegExp(\`^/(\${routing.locales.join('|')})\`)
3280
- const pathWithoutLocale = pathname.replace(localePattern, '') || '/'
503
+ export function NavHeader() {
504
+ const pathname = usePathname()
3281
505
 
3282
- // Auth check for protected paths
3283
- if (protectedPaths.some((p) => pathWithoutLocale.startsWith(p))) {
3284
- const session = getSessionCookie(request)
3285
- if (!session) {
3286
- const locale = pathname.match(localePattern)?.[1] || routing.defaultLocale
3287
- return NextResponse.redirect(new URL(\`/\${locale}/auth/sign-in\`, request.url))
3288
- }
506
+ function isActive(href: string) {
507
+ return href === '/' ? pathname === '/' : pathname.startsWith(href)
3289
508
  }
3290
509
 
3291
- // Locale routing
3292
- return intlMiddleware(request)
3293
- }
510
+ const linkClass = (href: string) =>
511
+ \`px-3 py-1.5 rounded-md text-sm transition-colors \${
512
+ isActive(href)
513
+ ? 'bg-zinc-200 text-zinc-900 dark:bg-zinc-800 dark:text-zinc-50'
514
+ : 'text-zinc-500 hover:text-zinc-700 hover:bg-zinc-100 dark:text-zinc-400 dark:hover:text-zinc-200 dark:hover:bg-zinc-800/50'
515
+ }\`
3294
516
 
3295
- export const config = {
3296
- matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
517
+ return (
518
+ <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
519
+ <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
520
+ <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
521
+ ${s} Admin
522
+ </span>
523
+ {links.map(({ href, label }) => (
524
+ <Link key={href} href={href} className={linkClass(href)}>
525
+ {label}
526
+ </Link>
527
+ ))}
528
+ <div className="ml-auto">
529
+ <ThemeToggle />
530
+ </div>
531
+ </nav>
532
+ </header>
533
+ )
3297
534
  }
3298
- `),await n(a(e,`app/layout.tsx`),`import './globals.css'
535
+ `),await e(d(c,`app/layout.tsx`),`import './globals.css'
3299
536
 
3300
537
  export default function RootLayout({ children }: { children: React.ReactNode }) {
3301
538
  return children
3302
539
  }
3303
- `),await n(a(e,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
540
+ `),await e(d(c,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
3304
541
  import { Geist, Geist_Mono } from 'next/font/google'
3305
542
  import { NextIntlClientProvider } from 'next-intl'
3306
543
  import { getMessages } from 'next-intl/server'
@@ -3320,7 +557,7 @@ const geistMono = Geist_Mono({
3320
557
  })
3321
558
 
3322
559
  export const metadata: Metadata = {
3323
- title: '${r}',
560
+ title: '${s} Admin',
3324
561
  description: 'Built with Lumi CMS Toolkit',
3325
562
  }
3326
563
 
@@ -3349,9 +586,9 @@ export default async function LocaleLayout({
3349
586
  </html>
3350
587
  )
3351
588
  }
3352
- `),await n(a(e,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
3353
- import { Article, Category } from '@/entities'
3354
- import { getToolkitApp } from '@/lib/app'
589
+ `),await e(d(c,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
590
+ import { Article, Category } from '@${s}/config/entities'
591
+ import { getToolkitApp } from '@${s}/config/app'
3355
592
 
3356
593
  export default async function HomePage() {
3357
594
  await getToolkitApp()
@@ -3366,7 +603,7 @@ export default async function HomePage() {
3366
603
  return (
3367
604
  <div className="min-h-screen p-8">
3368
605
  <div className="max-w-4xl mx-auto">
3369
- <h1 className="text-4xl font-bold mb-8">Welcome to ${r}</h1>
606
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${s}</h1>
3370
607
 
3371
608
  <section className="mb-12">
3372
609
  <h2 className="text-2xl font-semibold mb-4">
@@ -3413,49 +650,298 @@ export default async function HomePage() {
3413
650
  </div>
3414
651
  )
3415
652
  }
3416
- `)}async function y(e,t){await n(a(e,`scripts/generate-schema.ts`),`import { execSync } from 'node:child_process'
3417
- execSync('npx @murumets-ee/cli generate', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3418
- `),await n(a(e,`scripts/migrate.ts`),`import { execSync } from 'node:child_process'
3419
- execSync('npx @murumets-ee/cli migrate', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3420
- `),await n(a(e,`scripts/reset-db.ts`),`import { execSync } from 'node:child_process'
3421
- execSync('npx @murumets-ee/cli reset --force', { stdio: 'inherit', cwd: import.meta.dirname + '/..' })
3422
- `),await n(a(e,`scripts/worker.ts`),`/**
3423
- * Standalone queue worker process.
3424
- *
3425
- * Run separately from the Next.js web server:
3426
- * pnpm worker (or: tsx --env-file=.env scripts/worker.ts)
3427
- *
3428
- * The web server should set QUEUE_WORKER=false so it only enqueues jobs.
3429
- * This process handles job execution.
3430
- */
653
+ `),await e(d(c,`app/api/auth/[...all]/route.ts`),`import { toNextJsHandler } from 'better-auth/next-js'
654
+ import { auth } from '@${s}/config/auth'
655
+
656
+ const { GET, POST } = toNextJsHandler(auth)
657
+ export { GET, POST }
658
+ `),await e(d(c,`app/[locale]/auth/layout.tsx`),`import { AuthProviders } from './providers'
659
+ import type { ReactNode } from 'react'
660
+
661
+ export default function AuthLayout({ children }: { children: ReactNode }) {
662
+ return (
663
+ <AuthProviders>
664
+ <div className="flex min-h-[calc(100vh-3rem)] items-center justify-center px-4">
665
+ {children}
666
+ </div>
667
+ </AuthProviders>
668
+ )
669
+ }
670
+ `),await e(d(c,`app/[locale]/auth/providers.tsx`),`'use client'
671
+
672
+ import { AuthUIProvider } from '@murumets-ee/auth-ui'
673
+ import { authClient } from '@${s}/config/auth-client'
674
+ import Link from 'next/link'
675
+ import { useRouter } from 'next/navigation'
676
+ import { useLocale } from 'next-intl'
677
+ import type { ReactNode } from 'react'
678
+
679
+ export function AuthProviders({ children }: { children: ReactNode }) {
680
+ const router = useRouter()
681
+ const locale = useLocale()
682
+
683
+ return (
684
+ <AuthUIProvider
685
+ authClient={authClient as never}
686
+ basePath="/auth"
687
+ redirectTo="/"
688
+ Link={Link}
689
+ navigate={(url) => router.push(url)}
690
+ resetPasswordUrl={\`/\${locale}/auth/reset-password\`}
691
+ >
692
+ {children}
693
+ </AuthUIProvider>
694
+ )
695
+ }
696
+ `),await e(d(c,`app/[locale]/auth/sign-in/page.tsx`),`import { SignInForm } from '@murumets-ee/auth-ui'
697
+
698
+ export default function SignInPage() {
699
+ return <SignInForm />
700
+ }
701
+ `),await e(d(c,`app/[locale]/auth/sign-up/page.tsx`),`import { SignUpForm } from '@murumets-ee/auth-ui'
702
+
703
+ export default function SignUpPage() {
704
+ return <SignUpForm />
705
+ }
706
+ `),await e(d(c,`app/[locale]/auth/forgot-password/page.tsx`),`import { ForgotPasswordForm } from '@murumets-ee/auth-ui'
707
+
708
+ export default function ForgotPasswordPage() {
709
+ return <ForgotPasswordForm />
710
+ }
711
+ `),await e(d(c,`app/[locale]/auth/reset-password/page.tsx`),`import { Suspense } from 'react'
712
+ import { ResetPasswordContent } from './content'
713
+
714
+ export default function ResetPasswordPage() {
715
+ return (
716
+ <Suspense>
717
+ <ResetPasswordContent />
718
+ </Suspense>
719
+ )
720
+ }
721
+ `),await e(d(c,`app/[locale]/auth/reset-password/content.tsx`),`'use client'
722
+
723
+ import { useSearchParams } from 'next/navigation'
724
+ import { ResetPasswordForm } from '@murumets-ee/auth-ui'
725
+
726
+ export function ResetPasswordContent() {
727
+ const searchParams = useSearchParams()
728
+ const token = searchParams.get('token')
729
+
730
+ if (!token) {
731
+ return (
732
+ <div className="text-center">
733
+ <h1 className="text-2xl font-bold text-zinc-900 dark:text-zinc-50">
734
+ Invalid or expired link
735
+ </h1>
736
+ <p className="mt-2 text-zinc-500">
737
+ Please request a new password reset.
738
+ </p>
739
+ </div>
740
+ )
741
+ }
742
+
743
+ return <ResetPasswordForm token={token} />
744
+ }
745
+ `),await e(d(c,`app/[locale]/setup/page.tsx`),`import { redirect } from 'next/navigation'
746
+ import { sql } from 'drizzle-orm'
747
+ import { getToolkitApp } from '@${s}/config/app'
748
+ import { SetupForm } from './form'
749
+
750
+ export default async function SetupPage() {
751
+ const app = await getToolkitApp()
752
+ const result = await app.db.readOnly.execute<{ count: string }>(
753
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
754
+ )
755
+
756
+ if (Number(result[0]?.count) > 0) {
757
+ redirect('/')
758
+ }
759
+
760
+ return (
761
+ <div className="max-w-md mx-auto p-8">
762
+ <h1 className="text-2xl font-bold mb-2">Create Admin</h1>
763
+ <p className="text-sm text-zinc-500 dark:text-zinc-400 mb-6">
764
+ No users found. Create the first admin account.
765
+ </p>
766
+ <SetupForm />
767
+ </div>
768
+ )
769
+ }
770
+ `),await e(d(c,`app/[locale]/setup/form.tsx`),`'use client'
771
+
772
+ import { useState } from 'react'
773
+ import { createFirstAdmin } from './actions'
774
+
775
+ export function SetupForm() {
776
+ const [result, setResult] = useState<{ ok?: boolean; error?: string; email?: string } | null>(null)
777
+ const [loading, setLoading] = useState(false)
778
+
779
+ async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
780
+ e.preventDefault()
781
+ setLoading(true)
782
+ setResult(null)
783
+ const res = await createFirstAdmin(new FormData(e.currentTarget))
784
+ setResult(res)
785
+ setLoading(false)
786
+ }
787
+
788
+ if (result?.ok) {
789
+ return (
790
+ <div className="space-y-2">
791
+ <p className="text-green-600 dark:text-green-400">Admin created: {result.email}</p>
792
+ <a href="/auth/sign-in" className="text-blue-600 dark:text-blue-400 hover:underline">
793
+ Sign in &rarr;
794
+ </a>
795
+ </div>
796
+ )
797
+ }
798
+
799
+ return (
800
+ <form onSubmit={handleSubmit} className="flex flex-col gap-4">
801
+ <label className="text-sm font-medium">
802
+ Name
803
+ <input
804
+ name="name"
805
+ required
806
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
807
+ />
808
+ </label>
809
+ <label className="text-sm font-medium">
810
+ Email
811
+ <input
812
+ name="email"
813
+ type="email"
814
+ required
815
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
816
+ />
817
+ </label>
818
+ <label className="text-sm font-medium">
819
+ Password (min 8 chars)
820
+ <input
821
+ name="password"
822
+ type="password"
823
+ required
824
+ minLength={8}
825
+ className="mt-1 block w-full px-3 py-2 bg-zinc-100 dark:bg-zinc-900 border border-zinc-300 dark:border-zinc-700 rounded-md text-sm focus:outline-none focus:ring-1 focus:ring-blue-500"
826
+ />
827
+ </label>
828
+
829
+ {result?.error && <p className="text-red-600 dark:text-red-400 text-sm">{result.error}</p>}
830
+
831
+ <button
832
+ type="submit"
833
+ disabled={loading}
834
+ className="px-4 py-2 text-sm rounded-md bg-blue-600 text-white hover:bg-blue-500 disabled:opacity-50 transition-colors"
835
+ >
836
+ {loading ? 'Creating...' : 'Create Admin'}
837
+ </button>
838
+ </form>
839
+ )
840
+ }
841
+ `),await e(d(c,`app/[locale]/setup/actions.ts`),`'use server'
842
+
843
+ import { sql } from 'drizzle-orm'
844
+ import { getToolkitApp } from '@${s}/config/app'
845
+ import { getAuth } from '@murumets-ee/auth'
846
+
847
+ export async function createFirstAdmin(formData: FormData) {
848
+ const email = formData.get('email') as string
849
+ const password = formData.get('password') as string
850
+ const name = formData.get('name') as string
851
+
852
+ if (!email || !password || !name) {
853
+ return { error: 'All fields are required' }
854
+ }
855
+
856
+ if (password.length < 8) {
857
+ return { error: 'Password must be at least 8 characters' }
858
+ }
859
+
860
+ const app = await getToolkitApp()
861
+
862
+ const canCreate = await app.db.readWrite.transaction(async (tx) => {
863
+ await tx.execute(sql\`SELECT pg_advisory_xact_lock(1)\`)
864
+
865
+ const result = await tx.execute<{ count: string }>(
866
+ sql\`SELECT COUNT(*)::text as count FROM "user"\`,
867
+ )
868
+ return Number(result[0]?.count) === 0
869
+ })
870
+
871
+ if (!canCreate) {
872
+ return { error: 'Admin user already exists. Setup is complete.' }
873
+ }
874
+
875
+ const auth = getAuth()
876
+
877
+ const adminUser = await auth.api.signUpEmail({
878
+ body: { email, password, name },
879
+ })
880
+
881
+ await app.db.readWrite.execute(
882
+ sql\`UPDATE "user" SET role = 'admin' WHERE id = \${adminUser.user.id}\`,
883
+ )
884
+
885
+ return { ok: true, email: adminUser.user.email }
886
+ }
887
+ `)}async function y(i,a){let{name:o}=a,s=d(i,`apps/web`);n(`pnpm create next-app@16 ${s} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await r(s,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`,`pnpm-workspace.yaml`]),await t(d(s,`package.json`),{dependencies:{[`@${o}/config`]:`workspace:*`,"@murumets-ee/core":`^${m.myorgCore}`,"@murumets-ee/auth-ui":`^${m.myorgAuthUi}`,"next-intl":`^${m.nextIntl}`,"next-themes":`^${m.nextThemes}`,"lucide-react":`^${m.lucideReact}`},devDependencies:{"babel-plugin-react-compiler":m.babelReactCompiler}});let{appendToFile:c}=await import(`./utils-DHetVfSn.mjs`).then(e=>e.i);await c(d(s,`.gitignore`),`
888
+ # Environment
889
+ .env*
890
+ !.env.example
891
+ `),await e(d(s,`next.config.ts`),`import type { NextConfig } from 'next'
892
+ import createNextIntlPlugin from 'next-intl/plugin'
3431
893
 
3432
- import { createApp, setApp } from '@murumets-ee/core'
3433
- import config from '../toolkit.config'
894
+ const withNextIntl = createNextIntlPlugin('./i18n/request.ts')
3434
895
 
3435
- process.env.QUEUE_WORKER = 'true'
896
+ const nextConfig: NextConfig = {
897
+ reactCompiler: true,
898
+ transpilePackages: [
899
+ '@${o}/config',
900
+ '@murumets-ee/core',
901
+ '@murumets-ee/entity',
902
+ '@murumets-ee/db',
903
+ '@murumets-ee/logging',
904
+ '@murumets-ee/auth-ui',
905
+ ],
906
+ serverExternalPackages: ['drizzle-orm', 'postgres'],
907
+ }
3436
908
 
3437
- const app = await createApp(config)
3438
- setApp(app)
909
+ export default withNextIntl(nextConfig)
910
+ `),await e(d(s,`proxy.ts`),`import createMiddleware from 'next-intl/middleware'
911
+ import { routing } from './i18n/routing'
3439
912
 
3440
- app.logger.info('Queue worker process running. Press Ctrl+C to stop.')
913
+ export default createMiddleware(routing)
3441
914
 
3442
- const shutdown = async () => {
3443
- app.logger.info('Shutting down queue worker...')
915
+ export const config = {
916
+ matcher: '/((?!api|_next|_vercel|.*\\\\..*).*)',
917
+ }
918
+ `),await e(d(s,`i18n/routing.ts`),`import { defineRouting } from 'next-intl/routing'
3444
919
 
3445
- const worker = (globalThis as Record<symbol, unknown>)[
3446
- Symbol.for('@murumets-ee/queue:worker')
3447
- ] as { stop: () => Promise<void> } | undefined
920
+ export const routing = defineRouting({
921
+ locales: ['en'],
922
+ defaultLocale: 'en',
923
+ })
924
+ `),await e(d(s,`i18n/request.ts`),`import { getRequestConfig } from 'next-intl/server'
925
+ import { hasLocale } from 'next-intl'
926
+ import { routing } from './routing'
927
+ import { getAuthMessages } from '@murumets-ee/auth-ui/i18n'
3448
928
 
3449
- if (worker) {
3450
- await worker.stop()
3451
- }
929
+ export default getRequestConfig(async ({ requestLocale }) => {
930
+ const requested = await requestLocale
931
+ const locale = hasLocale(routing.locales, requested)
932
+ ? requested
933
+ : routing.defaultLocale
3452
934
 
3453
- process.exit(0)
3454
- }
935
+ const authMessages = await getAuthMessages(locale)
3455
936
 
3456
- process.on('SIGINT', shutdown)
3457
- process.on('SIGTERM', shutdown)
3458
- `)}async function b(e,t){let{name:r}=t;await n(a(e,`app/globals.css`),`@import "tailwindcss";
937
+ return {
938
+ locale,
939
+ messages: {
940
+ ...authMessages,
941
+ },
942
+ }
943
+ })
944
+ `),await e(d(s,`app/globals.css`),`@import "tailwindcss";
3459
945
  @source "../node_modules/@murumets-ee/auth-ui/dist";
3460
946
 
3461
947
  @custom-variant dark (&:where(.dark, .dark *));
@@ -3482,7 +968,7 @@ body {
3482
968
  color: var(--foreground);
3483
969
  font-family: Arial, Helvetica, sans-serif;
3484
970
  }
3485
- `),await n(a(e,`app/theme-provider.tsx`),`'use client'
971
+ `),await e(d(s,`app/theme-provider.tsx`),`'use client'
3486
972
 
3487
973
  import { ThemeProvider as NextThemesProvider } from 'next-themes'
3488
974
  import type { ReactNode } from 'react'
@@ -3499,7 +985,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
3499
985
  </NextThemesProvider>
3500
986
  )
3501
987
  }
3502
- `),await n(a(e,`app/theme-toggle.tsx`),`'use client'
988
+ `),await e(d(s,`app/theme-toggle.tsx`),`'use client'
3503
989
 
3504
990
  import { useTheme } from 'next-themes'
3505
991
  import { useState, useEffect } from 'react'
@@ -3526,7 +1012,7 @@ export function ThemeToggle() {
3526
1012
  </button>
3527
1013
  )
3528
1014
  }
3529
- `),await n(a(e,`app/nav-header.tsx`),`'use client'
1015
+ `),await e(d(s,`app/nav-header.tsx`),`'use client'
3530
1016
 
3531
1017
  import Link from 'next/link'
3532
1018
  import { usePathname } from 'next/navigation'
@@ -3534,8 +1020,6 @@ import { ThemeToggle } from './theme-toggle'
3534
1020
 
3535
1021
  const links = [
3536
1022
  { href: '/', label: 'Home' },
3537
- { href: '/auth/sign-in', label: 'Sign In' },
3538
- { href: '/setup', label: 'Setup' },
3539
1023
  ]
3540
1024
 
3541
1025
  export function NavHeader() {
@@ -3556,7 +1040,7 @@ export function NavHeader() {
3556
1040
  <header className="sticky top-0 z-50 backdrop-blur-md border-b bg-white/80 border-zinc-200 dark:bg-zinc-950/80 dark:border-zinc-800">
3557
1041
  <nav className="max-w-6xl mx-auto flex items-center gap-1 px-4 h-12">
3558
1042
  <span className="font-semibold text-sm text-zinc-700 dark:text-zinc-300 mr-3 select-none">
3559
- ${r}
1043
+ ${o}
3560
1044
  </span>
3561
1045
  {links.map(({ href, label }) => (
3562
1046
  <Link key={href} href={href} className={linkClass(href)}>
@@ -3570,106 +1054,123 @@ export function NavHeader() {
3570
1054
  </header>
3571
1055
  )
3572
1056
  }
3573
- `)}async function x(e,t){let{name:r}=t;await n(a(e,`toolkit.config.ts`),`import { auth } from '@murumets-ee/auth/plugin'
3574
- import { content } from '@murumets-ee/content/plugin'
3575
- import { defineConfig } from '@murumets-ee/core'
3576
- import { logging } from '@murumets-ee/logging/plugin'
3577
- import { mail, ResendMailProvider } from '@murumets-ee/mail'
3578
- import { media } from '@murumets-ee/media/plugin'
3579
- import { queue } from '@murumets-ee/queue/plugin'
3580
- import { settings } from '@murumets-ee/settings/plugin'
3581
- import { storage } from '@murumets-ee/storage/plugin'
3582
- import { taxonomy } from '@murumets-ee/taxonomy/plugin'
3583
- import { ticketing } from '@murumets-ee/ticketing/plugin'
3584
- import { Article, Category } from './entities'
3585
- import * as authSchema from './generated/auth-schema'
1057
+ `),await e(d(s,`app/layout.tsx`),`import './globals.css'
3586
1058
 
3587
- if (!process.env.DATABASE_URL) {
3588
- throw new Error('DATABASE_URL environment variable is required')
1059
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
1060
+ return children
3589
1061
  }
1062
+ `),await e(d(s,`app/[locale]/layout.tsx`),`import type { Metadata } from 'next'
1063
+ import { Geist, Geist_Mono } from 'next/font/google'
1064
+ import { NextIntlClientProvider } from 'next-intl'
1065
+ import { getMessages } from 'next-intl/server'
1066
+ import { notFound } from 'next/navigation'
1067
+ import { routing } from '@/i18n/routing'
1068
+ import { ThemeProvider } from '../theme-provider'
1069
+ import { NavHeader } from '../nav-header'
3590
1070
 
3591
- export default defineConfig({
3592
- db: {
3593
- url: process.env.DATABASE_URL,
3594
- poolMin: 2,
3595
- poolMax: 10,
3596
- },
3597
- logging: {
3598
- level: (process.env.LOG_LEVEL || 'info') as 'debug' | 'info' | 'warn' | 'error',
3599
- name: '${r}',
3600
- },
3601
- entities: [Category, Article],
3602
- plugins: [
3603
- auth({ providers: ['email'], schema: authSchema }),
3604
- content({
3605
- locales: [{ code: 'en', label: 'English' }],
3606
- defaultLocale: 'en',
3607
- }),
3608
- logging(),
3609
- settings(),
3610
- storage(),
3611
- media(),
3612
- taxonomy(),
3613
- queue(),
3614
- mail({
3615
- provider: process.env.RESEND_API_KEY
3616
- ? new ResendMailProvider({ apiKey: process.env.RESEND_API_KEY })
3617
- : undefined,
3618
- defaultFrom: process.env.MAIL_FROM ?? 'noreply@example.com',
3619
- webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
3620
- }),
3621
- ticketing({
3622
- csatSecret: process.env.CSAT_SECRET,
3623
- }),
3624
- ],
3625
- projectRoot: import.meta.dirname,
1071
+ const geistSans = Geist({
1072
+ variable: '--font-geist-sans',
1073
+ subsets: ['latin'],
3626
1074
  })
3627
- `),await n(a(e,`lib/app.ts`),`import { createApp, setApp, type ToolkitApp } from '@murumets-ee/core'
3628
- import config from '../toolkit.config'
3629
1075
 
3630
- let appInstance: ToolkitApp | null = null
1076
+ const geistMono = Geist_Mono({
1077
+ variable: '--font-geist-mono',
1078
+ subsets: ['latin'],
1079
+ })
3631
1080
 
3632
- export async function getToolkitApp(): Promise<ToolkitApp> {
3633
- if (!appInstance) {
3634
- appInstance = await createApp(config)
3635
- setApp(appInstance)
3636
- }
3637
- return appInstance
1081
+ export const metadata: Metadata = {
1082
+ title: '${o}',
1083
+ description: 'Built with Lumi CMS Toolkit',
3638
1084
  }
3639
- `),await n(a(e,`drizzle.config.ts`),`import type { Config } from 'drizzle-kit'
3640
1085
 
3641
- if (!process.env.DATABASE_URL) {
3642
- throw new Error('DATABASE_URL environment variable is required')
1086
+ export default async function LocaleLayout({
1087
+ children,
1088
+ params,
1089
+ }: {
1090
+ children: React.ReactNode
1091
+ params: Promise<{ locale: string }>
1092
+ }) {
1093
+ const { locale } = await params
1094
+ if (!routing.locales.includes(locale as any)) notFound()
1095
+
1096
+ const messages = await getMessages()
1097
+
1098
+ return (
1099
+ <html lang={locale} suppressHydrationWarning>
1100
+ <body className={\`\${geistSans.variable} \${geistMono.variable} antialiased\`}>
1101
+ <ThemeProvider>
1102
+ <NextIntlClientProvider locale={locale} messages={messages}>
1103
+ <NavHeader />
1104
+ {children}
1105
+ </NextIntlClientProvider>
1106
+ </ThemeProvider>
1107
+ </body>
1108
+ </html>
1109
+ )
3643
1110
  }
1111
+ `),await e(d(s,`app/[locale]/page.tsx`),`import { createQueryClient } from '@murumets-ee/core/clients'
1112
+ import { Article, Category } from '@${o}/config/entities'
1113
+ import { getToolkitApp } from '@${o}/config/app'
3644
1114
 
3645
- export default {
3646
- schema: ['./generated/schema.ts', './generated/auth-schema.ts'],
3647
- out: './migrations',
3648
- dialect: 'postgresql',
3649
- dbCredentials: {
3650
- url: process.env.DATABASE_URL,
3651
- },
3652
- } satisfies Config
3653
- `),await n(a(e,`auth.config.ts`),`import { betterAuth } from 'better-auth'
3654
- import { drizzleAdapter } from 'better-auth/adapters/drizzle'
3655
- import { admin } from 'better-auth/plugins'
3656
- import { organization } from 'better-auth/plugins/organization'
3657
- import { drizzle } from 'drizzle-orm/postgres-js'
3658
- import postgres from 'postgres'
1115
+ export default async function HomePage() {
1116
+ await getToolkitApp()
1117
+ const articles = createQueryClient(Article)
1118
+ const categories = createQueryClient(Category)
3659
1119
 
3660
- const sql = postgres(process.env.DATABASE_URL!)
3661
- const db = drizzle(sql)
1120
+ const [articleList, categoryList] = await Promise.all([
1121
+ articles.findMany({ limit: 10 }),
1122
+ categories.findMany({}),
1123
+ ])
3662
1124
 
3663
- export const auth = betterAuth({
3664
- database: drizzleAdapter(db, { provider: 'pg' }),
3665
- emailAndPassword: { enabled: true },
3666
- plugins: [
3667
- admin(),
3668
- organization(),
3669
- ],
3670
- })
3671
- `),await n(a(e,`generated/auth-schema.ts`),`// This file is generated by better-auth CLI.
3672
- // Run: npx @better-auth/cli generate --config auth.config.ts -y
3673
- export {}
3674
- `)}async function S(e,t){e.mode===`single`?await C(e,t):await w(e,t)}async function C(n,i){let o=a(process.cwd(),n.name);i?.(`Creating Next.js app...`),e(`pnpm create next-app@16 ${n.name} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await t(o,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`README.md`]),await r(a(o,`package.json`),{type:`module`,dependencies:{"@murumets-ee/core":`^${f.myorgCore}`,"@murumets-ee/db":`^${f.myorgDb}`,"@murumets-ee/entity":`^${f.myorgEntity}`,"@murumets-ee/logging":`^${f.myorgLogging}`,"@murumets-ee/auth":`^${f.myorgAuth}`,"@murumets-ee/auth-ui":`^${f.myorgAuthUi}`,"@murumets-ee/admin-ui":`^${f.myorgAdminUi}`,"@murumets-ee/content":`^${f.myorgContent}`,"@murumets-ee/settings":`^${f.myorgSettings}`,"@murumets-ee/storage":`^${f.myorgStorage}`,"@murumets-ee/media":`^${f.myorgMedia}`,"@murumets-ee/taxonomy":`^${f.myorgTaxonomy}`,"@murumets-ee/queue":`^${f.myorgQueue}`,"@murumets-ee/mail":`^${f.myorgMail}`,"@murumets-ee/ticketing":`^${f.myorgTicketing}`,"@murumets-ee/ticketing-ui":`^${f.myorgTicketingUi}`,"@murumets-ee/tokens":`^${f.myorgTokens}`,"@murumets-ee/editor":`^${f.myorgEditor}`,"@murumets-ee/blocks":`^${f.myorgBlocks}`,"@murumets-ee/ui":`^${f.myorgUi}`,"better-auth":`^${f.betterAuth}`,"drizzle-orm":`^${f.drizzleOrm}`,"next-intl":`^${f.nextIntl}`,"next-themes":`^${f.nextThemes}`,"lucide-react":`^${f.lucideReact}`,postgres:`^${f.postgres}`,"@tanstack/react-query":`^${f.tanstackReactQuery}`,"@tanstack/react-table":`^${f.tanstackReactTable}`,"react-hook-form":`^${f.reactHookForm}`,"@hookform/resolvers":`^${f.hookformResolvers}`,zod:`^${f.zod}`},devDependencies:{"drizzle-kit":`^${f.drizzleKit}`,tsx:`^${f.tsx}`,"babel-plugin-react-compiler":f.babelReactCompiler},scripts:{"db:generate":`tsx --env-file=.env scripts/generate-schema.ts`,"db:migrate:generate":`drizzle-kit generate`,"db:migrate":`tsx --env-file=.env scripts/migrate.ts`,"db:reset":`tsx --env-file=.env scripts/reset-db.ts`,worker:`tsx --env-file=.env scripts/worker.ts`}}),i?.(`Adding toolkit files...`),await l(o,n),await u(o,n),await x(o,n),await c(o,n),await d(o,n),await v(o,n),await b(o,n),await s(o,n),await y(o,n),n.installDeps&&(i?.(`Installing dependencies...`),e(`pnpm install`,{cwd:o}))}async function w(t,n){let r=a(process.cwd(),t.name);n?.(`Creating workspace...`),await p(r,t,n),t.installDeps&&(n?.(`Installing dependencies...`),e(`pnpm install`,{cwd:r}))}export{S as scaffold};
1125
+ return (
1126
+ <div className="min-h-screen p-8">
1127
+ <div className="max-w-4xl mx-auto">
1128
+ <h1 className="text-4xl font-bold mb-8">Welcome to ${o}</h1>
1129
+
1130
+ <section className="mb-12">
1131
+ <h2 className="text-2xl font-semibold mb-4">
1132
+ Categories ({categoryList.length})
1133
+ </h2>
1134
+ <div className="grid gap-4 md:grid-cols-2">
1135
+ {categoryList.map((cat) => (
1136
+ <div
1137
+ key={cat.id}
1138
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-4"
1139
+ >
1140
+ <h3 className="font-semibold text-lg">{cat.name}</h3>
1141
+ <p className="text-sm text-zinc-600 dark:text-zinc-400">
1142
+ {cat.description}
1143
+ </p>
1144
+ </div>
1145
+ ))}
1146
+ </div>
1147
+ </section>
1148
+
1149
+ <section>
1150
+ <h2 className="text-2xl font-semibold mb-4">
1151
+ Articles ({articleList.length})
1152
+ </h2>
1153
+ {articleList.length === 0 ? (
1154
+ <p className="text-zinc-500">No published articles yet.</p>
1155
+ ) : (
1156
+ <div className="space-y-4">
1157
+ {articleList.map((article) => (
1158
+ <div
1159
+ key={article.id}
1160
+ className="border border-zinc-200 dark:border-zinc-800 rounded-lg p-6"
1161
+ >
1162
+ <h3 className="text-xl font-bold">{article.title}</h3>
1163
+ <p className="text-zinc-600 dark:text-zinc-400 mt-2">
1164
+ {article.excerpt}
1165
+ </p>
1166
+ </div>
1167
+ ))}
1168
+ </div>
1169
+ )}
1170
+ </section>
1171
+ </div>
1172
+ </div>
1173
+ )
1174
+ }
1175
+ `)}const b=new Set([`.ts`,`.tsx`,`.mts`,`.json`,`.md`,`.yml`,`.yaml`,`.css`,`.html`]),x=new Set([`.env.example`,`Dockerfile`,`.gitignore`]),S=`__PROJECT_NAME__`,C=`__PROJECT_NAME_TITLE__`;async function w(e,t){e.mode===`single`?await T(e,t):await E(e,t)}async function T(e,t){let i=d(process.cwd(),e.name);t?.(`Creating Next.js app...`),n(`pnpm create next-app@16 ${e.name} --typescript --tailwind --no-eslint --app --no-src-dir --turbopack --react-compiler --skip-install --import-alias "@/*"`),await r(i,[`app/page.tsx`,`app/page.module.css`,`app/fonts`,`app/globals.css`,`README.md`]),t?.(`Extracting template...`),await D(e.template,i),t?.(`Personalizing template...`),await k(i,e.name),e.installDeps&&(t?.(`Installing dependencies...`),n(`pnpm install`,{cwd:i}))}async function E(e,t){let r=d(process.cwd(),e.name);t?.(`Creating workspace...`),await h(r,e,t),e.installDeps&&(t?.(`Installing dependencies...`),n(`pnpm install`,{cwd:r}))}async function D(e,t){let n=O(e);if(!i(n))throw Error(`Template tarball missing: ${n}\nRun \`pnpm --filter @murumets-ee/create build:templates\` first.`);await p({file:n,cwd:t})}function O(e){let t=u(f(import.meta.url)),n=t;for(let t=0;t<5;t++){let t=d(n,`package.json`);if(i(t))try{if(JSON.parse(a(t,`utf-8`)).name===`@murumets-ee/create`)return d(n,`templates`,`template-${e}.tar.gz`)}catch{}let r=u(n);if(r===n)break;n=r}throw Error(`Could not find @murumets-ee/create package root above ${t}. template-${e}.tar.gz cannot be located.`)}async function k(e,t){let n=N(t);for await(let r of j(e)){if(!M(r))continue;let e=await s(r,`utf-8`);!e.includes(S)&&!e.includes(C)||await l(r,e.split(C).join(n).split(S).join(t),`utf-8`)}}const A=new Set([`node_modules`,`dist`,`build`,`coverage`,`out`]);async function*j(e){let t=await c(e,{withFileTypes:!0});for(let n of t){let t=d(e,n.name);if(n.isDirectory()){if(A.has(n.name)||n.name.startsWith(`.`))continue;yield*j(t)}else n.isFile()&&(yield t)}}function M(e){let t=e.lastIndexOf(`/`),n=t>=0?e.slice(t+1):e;if(x.has(n))return!0;let r=n.lastIndexOf(`.`);if(r<0)return!1;let i=n.slice(r);return b.has(i)}function N(e){return e.split(/[-_\s]+/).filter(Boolean).map(e=>e[0]?.toUpperCase()+e.slice(1)).join(` `)}export{w as scaffold};
3675
1176
  //# sourceMappingURL=index.mjs.map