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/README.md +178 -0
- package/dist/frontend/chunk-qc4mbsnn.css +1 -0
- package/dist/frontend/chunk-wafvzqmx.js +44 -0
- package/dist/frontend/index.html +19 -0
- package/dist/frontend/vendor/vue.js +6 -0
- package/dist/main.d.ts +57 -0
- package/dist/main.js +169 -0
- package/package.json +31 -0
- package/src/main.ts +262 -0
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
|
+
}
|