marci-admin 0.0.1

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/src/main.ts ADDED
@@ -0,0 +1,262 @@
1
+ import { type MarciApp, HTTPError } from "@den59k/marci";
2
+ // import type { ViteDevServer } from "vite";
3
+ // import frontendIndex from '../../frontend/index.html'
4
+ import { join, normalize } from "node:path"
5
+ import { schema, unfoldSchema, type SchemaItem, type SchemaType } from "compact-json-schema";
6
+
7
+ type AdminPanelPlugin<T extends any[]> = (app: AdminPanel, ...options: T) => void | Promise<void>
8
+
9
+ type AuthMethod<T extends SchemaItem, K> = {
10
+ title?: string,
11
+ fields: T,
12
+ onLogin: (data: SchemaType<T>) => K | Promise<K>,
13
+ onRequest: (token: K) => void | Promise<void>
14
+ }
15
+
16
+ export type AdminPanel = {
17
+ createPage<T extends object, K extends keyof T>(options: CreatePageOptions): Page<T>,
18
+ register<T extends any[]>(func: AdminPanelPlugin<T>, ...options: T): void
19
+ registerAuthMethod<T extends SchemaItem, K>(method: AuthMethod<T, K>): void
20
+ }
21
+
22
+ export type AdminPanelMarci = ((app: MarciApp<any>) => Promise<void>) & AdminPanel
23
+
24
+ type PageEntry = {
25
+ title?: string,
26
+ path?: string,
27
+ table?: any
28
+ data?: (options: any) => Promise<any>,
29
+ itemData?: (id: any) => Promise<any>,
30
+ onInsert?: (obj: any) => Promise<void>,
31
+ onUpdate?: (key: any, obj: any) => Promise<void>,
32
+ onDelete?: (key: any[]) => Promise<void>
33
+ createForm?: any,
34
+ updateForm?: any,
35
+ primaryKey?: string | number | symbol,
36
+ primaryKeyType?: SchemaItem,
37
+ dataMapper: { map: (item: any) => any, key: string }[]
38
+ }
39
+
40
+ declare const __PRODUCTION__: boolean
41
+
42
+ export const createAdminPanel = (): AdminPanelMarci => {
43
+
44
+ const plugins: [AdminPanelPlugin<any>, any][] = []
45
+
46
+ const pages: PageEntry[] = []
47
+ let authMethod: AuthMethod<any, any> | null = null
48
+
49
+ const plugin = async (app: MarciApp) => {
50
+ for (let childPlugin of plugins as any) {
51
+ await childPlugin[0](plugin, ...childPlugin[1])
52
+ }
53
+
54
+ if (authMethod) {
55
+ app.addHook("onRequest", async (req) => {
56
+ if (req.raw.url.endsWith("/auth")) {
57
+ return
58
+ }
59
+ let token = req.raw.headers.get("Authorization")
60
+ if (!token) throw new HTTPError("Authorization required", 403)
61
+ if (token.startsWith("Bearer ")) token = token.slice(7)
62
+ await authMethod!.onRequest(token)
63
+ })
64
+
65
+ app.get("/api/admin/auth", () => {
66
+ return { title: authMethod!.title, fields: authMethod!.fields }
67
+ })
68
+ const loginSchema = authMethod.fields
69
+ app.post("/api/admin/auth", { body: loginSchema }, async (req) => {
70
+ // @ts-ignore
71
+ return await authMethod!.onLogin(req.body)
72
+ })
73
+ }
74
+
75
+ app.get("/api/admin/pages", async (req) => {
76
+ return pages.map(p => ({
77
+ path: p.path,
78
+ title: p.title
79
+ }))
80
+ })
81
+
82
+ for (let page of pages) {
83
+ const querySchema = schema({ take: "number?", skip: "number?" })
84
+
85
+ if (!page.data) {
86
+
87
+ } else if (page.dataMapper.length === 0) {
88
+ app.get(`/api/admin/data/${page.path}/items`, [{}, querySchema], (req) => {
89
+ const items = page.data!(req.query)
90
+ return items
91
+ })
92
+ } else {
93
+ app.get(`/api/admin/data/${page.path}/items`, [{}, querySchema], async (req) => {
94
+ const items = await page.data!(req.query)
95
+ for (let item of items) {
96
+ Object.assign(item, Object.fromEntries(page.dataMapper.map(i => [ i.key, i.map(item) ])))
97
+ }
98
+ return items
99
+ })
100
+ }
101
+
102
+ const paramsSchema = schema({ itemId: page.primaryKeyType ?? 'string' })
103
+ if (page.itemData) {
104
+ app.get(`/api/admin/data/${page.path}/items/:itemId`, [ paramsSchema ], async (req) => {
105
+ return await page.itemData!(req.params.itemId)
106
+ })
107
+ }
108
+
109
+ if (page.createForm && page.onInsert) {
110
+ app.post(`/api/admin/data/${page.path}/items`, [{}, page.createForm.schema], async (req) => {
111
+ // @ts-ignore
112
+ await page.onInsert!(req.body)
113
+ })
114
+ }
115
+
116
+ if (page.updateForm && page.onUpdate) {
117
+ app.post(`/api/admin/data/${page.path}/items/:itemId`, [paramsSchema, page.updateForm.schema], async (req) => {
118
+ // @ts-ignore
119
+ await page.onUpdate!(req.params.itemId, req.body)
120
+ })
121
+ }
122
+
123
+ if (page.onDelete) {
124
+ const deleteSchema = schema({ itemIds: { type: "array", items: page.primaryKeyType ?? 'string' } })
125
+
126
+ app.delete(`/api/admin/data/${page.path}/items`, [{}, deleteSchema], async (req) => {
127
+ // @ts-ignore
128
+ await page.onDelete!(req.body.itemIds)
129
+ })
130
+ }
131
+
132
+ app.get(`/api/admin/pages/${page.path}`, async (req) => {
133
+ return {
134
+ title: page.title,
135
+ path: page.path,
136
+ table: page.table,
137
+ primaryKey: page.primaryKey,
138
+ createForm: page.createForm,
139
+ updateForm: page.updateForm,
140
+ itemAccess: !!page.itemData,
141
+ allowDelete: page.onDelete ? true: undefined
142
+ }
143
+ })
144
+ }
145
+
146
+ const routesRaw = (app as any).routes
147
+
148
+ if (typeof __PRODUCTION__ !== "undefined" && __PRODUCTION__) {
149
+ const frontendDir = join(import.meta.dir, "frontend")
150
+
151
+ // index.html читаем один раз и держим в памяти
152
+ const indexHtml = await Bun.file(join(frontendDir, "index.html")).text()
153
+ const serveIndex = () =>
154
+ new Response(indexHtml, { headers: { "Content-Type": "text/html; charset=utf-8" } })
155
+
156
+ const ASSETS_PREFIX = "/admin/assets/"
157
+ routesRaw[ASSETS_PREFIX + "*"] = (req: Request) => {
158
+ const { pathname } = new URL(req.url)
159
+ const rel = normalize(pathname.slice(ASSETS_PREFIX.length))
160
+ if (rel.startsWith("..")) return new Response("Not found", { status: 404 }) // защита от path traversal
161
+ return new Response(Bun.file(join(frontendDir, rel))) // Content-Type Bun проставит по расширению
162
+ }
163
+
164
+ // SPA-fallback
165
+ routesRaw["/admin"] = serveIndex
166
+ routesRaw["/admin/*"] = serveIndex
167
+ } else {
168
+ routesRaw["/admin/*"] = frontendIndex
169
+ routesRaw["/admin"] = frontendIndex
170
+ }
171
+
172
+ }
173
+
174
+ plugin.createPage = <T extends object>(options: CreatePageOptions) => {
175
+
176
+ const currentPage: PageEntry = { path: options.path, title: options.title, dataMapper: [] }
177
+ pages.push(currentPage)
178
+
179
+ const data: PageWithPrimaryKey<T, "string", T> = {
180
+ table(table) {
181
+ currentPage.table = table
182
+ for (let column in table) {
183
+ if (!table[column]) continue
184
+ if (table[column] === true) {
185
+ table[column] = { title: column }
186
+ continue
187
+ }
188
+ if (typeof table[column] === "object" && table[column].map) {
189
+ const key = "@"+column
190
+ currentPage.dataMapper.push({ key, map: table[column].map })
191
+ Object.assign(table[column], { map: undefined, key, sortable: false })
192
+ }
193
+ }
194
+ return this
195
+ },
196
+ primaryKey(key, type?: SchemaItem){
197
+ currentPage.primaryKey = key
198
+ currentPage.primaryKeyType = type ?? "string"
199
+ return this as any
200
+ },
201
+ data(query) {
202
+ currentPage.data = query
203
+ return this as any
204
+ },
205
+ item(query) {
206
+ currentPage.itemData = query
207
+ return this as any
208
+ },
209
+ createForm(schema, onInsert) {
210
+ currentPage.createForm = { schema: unfoldSchema(schema) }
211
+ currentPage.onInsert = onInsert
212
+ return this
213
+ },
214
+ updateForm(schema, onUpdate) {
215
+ currentPage.updateForm = { schema: unfoldSchema(schema) }
216
+ currentPage.onUpdate = onUpdate
217
+ return this
218
+ },
219
+ onDelete(onDelete) {
220
+ currentPage.onDelete = onDelete
221
+ return this
222
+ }
223
+ }
224
+ return data
225
+ }
226
+
227
+ plugin.register = <T extends any[]>(func: AdminPanelPlugin<T>, ...options: T) => {
228
+ plugins.push([func, options])
229
+ }
230
+
231
+ plugin.registerAuthMethod = <T extends SchemaItem>(method: AuthMethod<T, any>) => {
232
+ method.fields = unfoldSchema(method.fields)
233
+ authMethod = method
234
+ }
235
+
236
+ return plugin as any
237
+ }
238
+
239
+ type CreatePageOptions = {
240
+ title?: string
241
+ path?: string
242
+ }
243
+
244
+ type ColumnType<T> = {
245
+ title?: string, map?: (item: T) => any,
246
+ width?: number | string
247
+ }
248
+
249
+ type TableSchema<T, S extends string | number | symbol> = Record<S, true | ColumnType<T>>
250
+
251
+ interface Page<T extends object> {
252
+ table<S extends keyof T | `_${string}`>(table: TableSchema<T, S>): this,
253
+ createForm<S extends SchemaItem>(schema: S, onInsert: (data: SchemaType<S>) => Promise<void>): this,
254
+ primaryKey<KeyType extends SchemaItem = "string">(key: keyof T, type?: KeyType): PageWithPrimaryKey<T, KeyType, T>,
255
+ data<T2 extends object>(query: (options: { skip: number, take: number }) => Promise<T2[]>): Page<T2>,
256
+ }
257
+
258
+ interface PageWithPrimaryKey<T extends object, KeyType extends SchemaItem, Item extends object> extends Page<T> {
259
+ item<T2 extends object>(query: (itemId: SchemaType<KeyType>) => Promise<T2 | null>): PageWithPrimaryKey<T, KeyType, T2>,
260
+ updateForm<S extends SchemaItem>(schema: S, onUpdate: (id: SchemaType<KeyType>, data: SchemaType<S>) => Promise<void>): PageWithPrimaryKey<T, KeyType, Item>,
261
+ onDelete(onDelete: (ids: SchemaType<KeyType>[]) => Promise<void>): PageWithPrimaryKey<T, KeyType, Item>
262
+ }