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