@alstar/studio 0.0.0-beta.11 → 0.0.0-beta.13
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/api/api-key.ts +3 -11
- package/api/auth.ts +0 -2
- package/api/backup.ts +23 -14
- package/api/block.ts +32 -70
- package/components/AdminPanel.ts +14 -2
- package/components/BlockFieldRenderer.ts +16 -4
- package/components/Entries.ts +6 -2
- package/components/Settings.ts +5 -96
- package/components/SiteLayout.ts +3 -4
- package/components/fields/Slug.ts +22 -13
- package/components/fields/Text.ts +20 -4
- package/components/fields/index.ts +1 -1
- package/components/icons.ts +19 -0
- package/components/settings/ApiKeys.ts +122 -0
- package/components/settings/Backup.ts +86 -0
- package/components/{Users.ts → settings/Users.ts} +21 -4
- package/index.ts +13 -1
- package/package.json +3 -3
- package/pages/error.ts +1 -1
- package/pages/register.ts +3 -2
- package/public/studio/css/settings.css +4 -16
- package/public/studio/main.css +0 -1
- package/public/studio/main.js +1 -0
- package/utils/renderSSE.ts +27 -0
- package/utils/slugify.ts +3 -1
- package/components/Backup.ts +0 -13
- package/utils/build-structure-path.ts +0 -43
package/api/api-key.ts
CHANGED
|
@@ -4,11 +4,9 @@ import { Hono } from 'hono'
|
|
|
4
4
|
import { streamSSE } from 'hono/streaming'
|
|
5
5
|
import { db } from '@alstar/db'
|
|
6
6
|
import crypto from 'node:crypto'
|
|
7
|
-
|
|
8
|
-
import { stripNewlines } from '../utils/strip-newlines.ts'
|
|
9
7
|
import { sql } from '../utils/sql.ts'
|
|
10
|
-
import Settings from '../components/Settings.ts'
|
|
11
8
|
import { createHash } from '../utils/create-hash.ts'
|
|
9
|
+
import { renderSSE } from '../utils/renderSSE.ts'
|
|
12
10
|
|
|
13
11
|
const app = new Hono<{ Bindings: HttpBindings }>()
|
|
14
12
|
|
|
@@ -36,10 +34,7 @@ app.post('/api-key', async (c) => {
|
|
|
36
34
|
data: `signals { apiKey: '${apiKey}', name: '' }`,
|
|
37
35
|
})
|
|
38
36
|
|
|
39
|
-
await stream
|
|
40
|
-
event: 'datastar-patch-elements',
|
|
41
|
-
data: `elements ${stripNewlines(Settings())}`,
|
|
42
|
-
})
|
|
37
|
+
await renderSSE(stream, c)
|
|
43
38
|
})
|
|
44
39
|
})
|
|
45
40
|
|
|
@@ -59,10 +54,7 @@ app.delete('/api-key', async (c) => {
|
|
|
59
54
|
`)
|
|
60
55
|
.run(value)
|
|
61
56
|
|
|
62
|
-
await stream
|
|
63
|
-
event: 'datastar-patch-elements',
|
|
64
|
-
data: `elements ${stripNewlines(Settings())}`,
|
|
65
|
-
})
|
|
57
|
+
await renderSSE(stream, c)
|
|
66
58
|
})
|
|
67
59
|
})
|
|
68
60
|
|
package/api/auth.ts
CHANGED
package/api/backup.ts
CHANGED
|
@@ -5,6 +5,10 @@ import { Hono } from 'hono'
|
|
|
5
5
|
import { type HttpBindings } from '@hono/node-server'
|
|
6
6
|
import { streamSSE } from 'hono/streaming'
|
|
7
7
|
import { db } from '@alstar/db'
|
|
8
|
+
import { stripNewlines } from '../utils/strip-newlines.ts'
|
|
9
|
+
import Settings from '../components/Settings.ts'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import { renderSSE } from '../utils/renderSSE.ts'
|
|
8
12
|
|
|
9
13
|
const app = new Hono<{ Bindings: HttpBindings }>()
|
|
10
14
|
|
|
@@ -17,23 +21,28 @@ app.post('/backup', async (c) => {
|
|
|
17
21
|
|
|
18
22
|
await backup(db.database, name)
|
|
19
23
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return streamSSE(c, async (stream) => {
|
|
23
|
-
await stream.writeSSE({
|
|
24
|
-
event: 'datastar-patch-signals',
|
|
25
|
-
data: `signals { status: 200, message: '${name} created' }`,
|
|
26
|
-
})
|
|
27
|
-
})
|
|
24
|
+
return streamSSE(c, async (stream) => await renderSSE(stream, c))
|
|
28
25
|
} catch (error) {
|
|
29
26
|
console.log(error)
|
|
27
|
+
return c.json({ status: 500, message: 'Something went wrong' })
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
app.delete('/backup', async (c) => {
|
|
32
|
+
const formData = await c.req.formData()
|
|
33
|
+
const data = Object.fromEntries(formData.entries())
|
|
34
|
+
|
|
35
|
+
if (!data.filename) {
|
|
36
|
+
return c.json({ status: 404, message: 'Need a filename to remove' })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
await fsp.rm(path.join('./backups', data.filename.toString()))
|
|
30
41
|
|
|
31
|
-
return streamSSE(c, async (stream) =>
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
})
|
|
36
|
-
})
|
|
42
|
+
return streamSSE(c, async (stream) => await renderSSE(stream, c))
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.log(error)
|
|
45
|
+
return c.json({ status: 500, message: 'Something went wrong' })
|
|
37
46
|
}
|
|
38
47
|
})
|
|
39
48
|
|
package/api/block.ts
CHANGED
|
@@ -2,18 +2,14 @@ import { type HttpBindings } from '@hono/node-server'
|
|
|
2
2
|
import { ServerSentEventGenerator } from '@starfederation/datastar-sdk'
|
|
3
3
|
import { Hono } from 'hono'
|
|
4
4
|
import { streamSSE } from 'hono/streaming'
|
|
5
|
-
import { stripNewlines } from '../utils/strip-newlines.ts'
|
|
6
5
|
import { sql } from '../utils/sql.ts'
|
|
7
|
-
import { type Structure } from '../types.ts'
|
|
8
6
|
import { db } from '@alstar/db'
|
|
9
|
-
import Entry from '../components/Entry.ts'
|
|
10
7
|
import {
|
|
11
8
|
blockWithChildren,
|
|
12
9
|
deleteBlockWithChildren,
|
|
13
10
|
} from '../queries/block-with-children.ts'
|
|
14
|
-
import AdminPanel from '../components/AdminPanel.ts'
|
|
15
11
|
import { query } from '../queries/index.ts'
|
|
16
|
-
import {
|
|
12
|
+
import { renderSSE } from '../utils/renderSSE.ts'
|
|
17
13
|
|
|
18
14
|
const app = new Hono<{ Bindings: HttpBindings }>()
|
|
19
15
|
|
|
@@ -22,40 +18,19 @@ app.post('/block', async (c) => {
|
|
|
22
18
|
const formData = await c.req.formData()
|
|
23
19
|
const data = Object.fromEntries(formData.entries())
|
|
24
20
|
|
|
25
|
-
const
|
|
21
|
+
const definedData = JSON.parse(
|
|
22
|
+
JSON.stringify({
|
|
23
|
+
type: data.type?.toString(),
|
|
24
|
+
name: data.name?.toString(),
|
|
25
|
+
label: data.label?.toString(),
|
|
26
|
+
parent_id: data.parent_id?.toString(),
|
|
27
|
+
sort_order: data.sort_order?.toString(),
|
|
28
|
+
}),
|
|
29
|
+
)
|
|
26
30
|
|
|
27
|
-
|
|
31
|
+
db.insertInto('blocks', definedData)
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
name: data.name?.toString(),
|
|
31
|
-
label: row.label,
|
|
32
|
-
type: row.type,
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
await stream.writeSSE({
|
|
36
|
-
event: 'datastar-patch-elements',
|
|
37
|
-
data: `elements ${stripNewlines(AdminPanel())}`,
|
|
38
|
-
})
|
|
39
|
-
})
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
app.post('/new-block', async (c) => {
|
|
43
|
-
return streamSSE(c, async (stream) => {
|
|
44
|
-
const formData = await c.req.formData()
|
|
45
|
-
const data = Object.fromEntries(formData)
|
|
46
|
-
|
|
47
|
-
db.insertInto('blocks', {
|
|
48
|
-
type: data.type.toString(),
|
|
49
|
-
name: data.name.toString(),
|
|
50
|
-
label: data.label.toString(),
|
|
51
|
-
parent_id: data.parent_id.toString(),
|
|
52
|
-
sort_order: data.sort_order.toString(),
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
await stream.writeSSE({
|
|
56
|
-
event: 'datastar-patch-elements',
|
|
57
|
-
data: `elements ${stripNewlines(Entry({ entryId: parseInt(data.entry_id.toString()) }))}`,
|
|
58
|
-
})
|
|
33
|
+
await renderSSE(stream, c)
|
|
59
34
|
})
|
|
60
35
|
})
|
|
61
36
|
|
|
@@ -65,10 +40,9 @@ app.patch('/block', async (c) => {
|
|
|
65
40
|
|
|
66
41
|
const id = formData.get('id')?.toString()
|
|
67
42
|
const value = formData.get('value')?.toString()
|
|
68
|
-
const name = formData.get('name')?.toString()
|
|
69
|
-
const entryId = formData.get('entryId')?.toString()
|
|
70
|
-
const parentId = formData.get('parentId')?.toString()
|
|
71
|
-
// const sortOrder = formData.get('sort_order')?.toString()
|
|
43
|
+
// const name = formData.get('name')?.toString()
|
|
44
|
+
// const entryId = formData.get('entryId')?.toString()
|
|
45
|
+
// const parentId = formData.get('parentId')?.toString()
|
|
72
46
|
|
|
73
47
|
if (!id || !value) return
|
|
74
48
|
|
|
@@ -82,18 +56,20 @@ app.patch('/block', async (c) => {
|
|
|
82
56
|
|
|
83
57
|
transaction.run(value, id)
|
|
84
58
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
59
|
+
await renderSSE(stream, c)
|
|
60
|
+
|
|
61
|
+
// if (entryId === parentId && name?.toString() === 'title') {
|
|
62
|
+
// const rootBlock = query.block({
|
|
63
|
+
// id: parentId?.toString() || null,
|
|
64
|
+
// })
|
|
65
|
+
|
|
66
|
+
// if (rootBlock.type !== 'single') {
|
|
67
|
+
// await stream.writeSSE({
|
|
68
|
+
// event: 'datastar-patch-elements',
|
|
69
|
+
// data: `elements <a href="/studio/entry/${entryId}" id="block_link_${entryId}">${value}</a>`,
|
|
70
|
+
// })
|
|
71
|
+
// }
|
|
72
|
+
// }
|
|
97
73
|
})
|
|
98
74
|
})
|
|
99
75
|
|
|
@@ -116,27 +92,13 @@ app.patch('/value', async (c) => {
|
|
|
116
92
|
app.delete('/block', async (c) => {
|
|
117
93
|
return streamSSE(c, async (stream) => {
|
|
118
94
|
const formData = await c.req.formData()
|
|
119
|
-
|
|
120
95
|
const id = formData.get('id')?.toString()
|
|
121
|
-
const entryId = formData.get('entry_id')?.toString()
|
|
122
96
|
|
|
123
97
|
if (!id) return
|
|
124
98
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (entryId) {
|
|
130
|
-
await stream.writeSSE({
|
|
131
|
-
event: 'datastar-patch-elements',
|
|
132
|
-
data: `elements ${stripNewlines(Entry({ entryId: parseInt(entryId.toString()) }))}`,
|
|
133
|
-
})
|
|
134
|
-
} else {
|
|
135
|
-
await stream.writeSSE({
|
|
136
|
-
event: 'datastar-patch-elements',
|
|
137
|
-
data: `elements ${stripNewlines(AdminPanel())}`,
|
|
138
|
-
})
|
|
139
|
-
}
|
|
99
|
+
db.database.prepare(deleteBlockWithChildren).all(id)
|
|
100
|
+
|
|
101
|
+
await renderSSE(stream, c)
|
|
140
102
|
})
|
|
141
103
|
})
|
|
142
104
|
|
package/components/AdminPanel.ts
CHANGED
|
@@ -37,10 +37,20 @@ export default () => {
|
|
|
37
37
|
|
|
38
38
|
return html`
|
|
39
39
|
<form
|
|
40
|
-
data-on-submit="@post('/studio/api/block', {
|
|
40
|
+
data-on-submit="@post('/studio/api/block', {
|
|
41
|
+
contentType: 'form',
|
|
42
|
+
headers: {
|
|
43
|
+
'render': 'AdminPanel',
|
|
44
|
+
},
|
|
45
|
+
})"
|
|
41
46
|
style="display: flex; align-items: center; gap: 1rem;"
|
|
42
47
|
>
|
|
48
|
+
<input type="hidden" name="return" value="AdminPanel" />
|
|
49
|
+
|
|
43
50
|
<input type="hidden" name="name" value="${name}" />
|
|
51
|
+
<input type="hidden" name="label" value="${block.label}" />
|
|
52
|
+
<input type="hidden" name="type" value="${block.type}" />
|
|
53
|
+
|
|
44
54
|
<button
|
|
45
55
|
class="ghost"
|
|
46
56
|
style="padding: 10px; margin: 0 -13px; display: flex;"
|
|
@@ -49,6 +59,7 @@ export default () => {
|
|
|
49
59
|
>
|
|
50
60
|
${icons.newDocument}
|
|
51
61
|
</button>
|
|
62
|
+
|
|
52
63
|
<p style="user-select: none;"><small>${block.label}</small></p>
|
|
53
64
|
</form>
|
|
54
65
|
|
|
@@ -69,8 +80,9 @@ export default () => {
|
|
|
69
80
|
>
|
|
70
81
|
${icons.cog}
|
|
71
82
|
</a>
|
|
72
|
-
|
|
83
|
+
|
|
73
84
|
<a
|
|
85
|
+
data-barba-prevent
|
|
74
86
|
role="button"
|
|
75
87
|
href="/"
|
|
76
88
|
class="ghost"
|
|
@@ -42,7 +42,13 @@ export default (props: {
|
|
|
42
42
|
return html`
|
|
43
43
|
<li>
|
|
44
44
|
<form
|
|
45
|
-
data-on-submit="@post('/studio/api/
|
|
45
|
+
data-on-submit="@post('/studio/api/block', {
|
|
46
|
+
contentType: 'form',
|
|
47
|
+
headers: {
|
|
48
|
+
render: 'Entry',
|
|
49
|
+
props: '${JSON.stringify({ entryId: entryId })}'
|
|
50
|
+
}
|
|
51
|
+
})"
|
|
46
52
|
>
|
|
47
53
|
<button type="submit" class="ghost">${block.label}</button>
|
|
48
54
|
<input type="hidden" name="type" value="${block.type}" />
|
|
@@ -66,7 +72,7 @@ export default (props: {
|
|
|
66
72
|
<hr style="margin-top: 0;" />
|
|
67
73
|
|
|
68
74
|
<sortable-list data-id="${data.id}">
|
|
69
|
-
${rows.map((row
|
|
75
|
+
${rows.map((row) => {
|
|
70
76
|
const [name, struct] =
|
|
71
77
|
entries.find(([name]) => name === row.name) || []
|
|
72
78
|
|
|
@@ -91,7 +97,13 @@ export default (props: {
|
|
|
91
97
|
</button>
|
|
92
98
|
|
|
93
99
|
<form
|
|
94
|
-
data-on-submit="@delete('/studio/api/block', {
|
|
100
|
+
data-on-submit="@delete('/studio/api/block', {
|
|
101
|
+
contentType: 'form',
|
|
102
|
+
headers: {
|
|
103
|
+
render: 'Entry',
|
|
104
|
+
props: '${JSON.stringify({ entryId: entryId })}'
|
|
105
|
+
}
|
|
106
|
+
})"
|
|
95
107
|
>
|
|
96
108
|
<button
|
|
97
109
|
type="submit"
|
|
@@ -102,8 +114,8 @@ export default (props: {
|
|
|
102
114
|
>
|
|
103
115
|
${icons.x}
|
|
104
116
|
</button>
|
|
117
|
+
|
|
105
118
|
<input type="hidden" name="id" value="${row.id}" />
|
|
106
|
-
<input type="hidden" name="entry_id" value="${entryId}" />
|
|
107
119
|
</form>
|
|
108
120
|
</aside>
|
|
109
121
|
</header>
|
package/components/Entries.ts
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { html } from 'hono/html'
|
|
2
2
|
import { query } from '../index.ts'
|
|
3
3
|
import * as icons from './icons.ts'
|
|
4
|
-
import type { BlockDef } from '../types.ts'
|
|
5
4
|
|
|
6
5
|
export default ({ name }: { name: string }) => {
|
|
7
6
|
const entries = query.blocks({ parent_id: null, status: 'enabled', name })
|
|
@@ -22,7 +21,12 @@ export default ({ name }: { name: string }) => {
|
|
|
22
21
|
</a>
|
|
23
22
|
|
|
24
23
|
<form
|
|
25
|
-
data-on-submit="@delete('/studio/api/block', {
|
|
24
|
+
data-on-submit="@delete('/studio/api/block', {
|
|
25
|
+
contentType: 'form',
|
|
26
|
+
headers: {
|
|
27
|
+
render: 'AdminPanel'
|
|
28
|
+
}
|
|
29
|
+
})"
|
|
26
30
|
>
|
|
27
31
|
<input type="hidden" name="id" value="${block.id}" />
|
|
28
32
|
<button
|
package/components/Settings.ts
CHANGED
|
@@ -1,104 +1,13 @@
|
|
|
1
|
-
import { db } from '@alstar/db'
|
|
2
1
|
import { html } from 'hono/html'
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
import
|
|
6
|
-
import Users from './Users.ts'
|
|
2
|
+
import Backup from './settings/Backup.ts'
|
|
3
|
+
import Users from './settings/Users.ts'
|
|
4
|
+
import ApiKeys from './settings/ApiKeys.ts'
|
|
7
5
|
|
|
8
6
|
export default () => {
|
|
9
|
-
const apiKeys = db.database
|
|
10
|
-
.prepare(sql`
|
|
11
|
-
select
|
|
12
|
-
*
|
|
13
|
-
from
|
|
14
|
-
api_keys
|
|
15
|
-
`)
|
|
16
|
-
.all()
|
|
17
|
-
|
|
18
7
|
return html`
|
|
19
|
-
<div id="settings"
|
|
8
|
+
<div id="settings">
|
|
20
9
|
<!-- <h1>Settings</h1> -->
|
|
21
|
-
|
|
22
|
-
<header>API Keys</header>
|
|
23
|
-
|
|
24
|
-
<ul>
|
|
25
|
-
${apiKeys.map((apiKey) => {
|
|
26
|
-
return html`<li>
|
|
27
|
-
<p>${apiKey.name}</p>
|
|
28
|
-
<input type="text" disabled value="${apiKey.hint}" />
|
|
29
|
-
<form
|
|
30
|
-
data-on-submit="@delete('/studio/api/api-key', { contentType: 'form' })"
|
|
31
|
-
>
|
|
32
|
-
<button
|
|
33
|
-
data-tooltip="Delete API key"
|
|
34
|
-
data-placement="left"
|
|
35
|
-
type="submit"
|
|
36
|
-
class="ghost"
|
|
37
|
-
>
|
|
38
|
-
${icons.trash}
|
|
39
|
-
</button>
|
|
40
|
-
|
|
41
|
-
<input type="hidden" name="value" value="${apiKey.value}" />
|
|
42
|
-
</form>
|
|
43
|
-
</li>`
|
|
44
|
-
})}
|
|
45
|
-
</ul>
|
|
46
|
-
|
|
47
|
-
<form
|
|
48
|
-
data-on-submit="@post('/studio/api/api-key', { contentType: 'form' })"
|
|
49
|
-
>
|
|
50
|
-
<label for="api_key_name"><small>Generate API Key</small></label>
|
|
51
|
-
|
|
52
|
-
<input
|
|
53
|
-
data-bind="name"
|
|
54
|
-
type="text"
|
|
55
|
-
name="name"
|
|
56
|
-
id="api_key_name"
|
|
57
|
-
placeholder="Name"
|
|
58
|
-
/>
|
|
59
|
-
|
|
60
|
-
<button type="submit" class="ghost">Generate key</button>
|
|
61
|
-
</form>
|
|
62
|
-
|
|
63
|
-
<dialog data-attr="{ open: $apiKey !== '' }">
|
|
64
|
-
<article>
|
|
65
|
-
<header>
|
|
66
|
-
<p>API Key</p>
|
|
67
|
-
</header>
|
|
68
|
-
<p>Be sure to save this key, as it wont be shown again.</p>
|
|
69
|
-
|
|
70
|
-
<div style="display: flex; gap: 1rem; align-items: center;">
|
|
71
|
-
<h3 style="margin: 0;">
|
|
72
|
-
<code data-text="$apiKey"></code>
|
|
73
|
-
</h3>
|
|
74
|
-
|
|
75
|
-
<button
|
|
76
|
-
style="display: flex; align-items: center;"
|
|
77
|
-
data-attr="{ id: $apiKey }"
|
|
78
|
-
data-on-click="navigator.clipboard.writeText($apiKey); $copied = true"
|
|
79
|
-
class="ghost"
|
|
80
|
-
aria-label="Copy key to clipboard"
|
|
81
|
-
>
|
|
82
|
-
${icons.clipboard}
|
|
83
|
-
<span style="display: none; margin-left: 0.5rem; color: green;" data-style="{ display: $copied && 'block' }">Copied</span>
|
|
84
|
-
</button>
|
|
85
|
-
</div>
|
|
86
|
-
|
|
87
|
-
<footer>
|
|
88
|
-
<button
|
|
89
|
-
class="ghost"
|
|
90
|
-
data-on-click="$apiKey = ''; $copied = false; evt.target.closest('dialog')?.close()"
|
|
91
|
-
>
|
|
92
|
-
Close
|
|
93
|
-
</button>
|
|
94
|
-
</footer>
|
|
95
|
-
</article>
|
|
96
|
-
</dialog>
|
|
97
|
-
</article>
|
|
98
|
-
|
|
99
|
-
${Backup()}
|
|
100
|
-
|
|
101
|
-
${Users()}
|
|
10
|
+
${ApiKeys()} ${Backup()} ${Users()}
|
|
102
11
|
</div>
|
|
103
12
|
`
|
|
104
13
|
}
|
package/components/SiteLayout.ts
CHANGED
|
@@ -11,16 +11,15 @@ export default (
|
|
|
11
11
|
| Promise<HtmlEscapedString>,
|
|
12
12
|
includeAdminPanel = true,
|
|
13
13
|
) => {
|
|
14
|
+
const title = studioConfig.siteName ? studioConfig.siteName + ' | ' : ''
|
|
15
|
+
|
|
14
16
|
return html`
|
|
15
17
|
<!DOCTYPE html>
|
|
16
18
|
<html lang="en">
|
|
17
19
|
<head>
|
|
18
20
|
<meta charset="UTF-8" />
|
|
19
21
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
20
|
-
<title>
|
|
21
|
-
${studioConfig.siteName ? studioConfig.siteName + ' | ' : ''}Alstar
|
|
22
|
-
Studio
|
|
23
|
-
</title>
|
|
22
|
+
<title>${title}Alstar Studio</title>
|
|
24
23
|
|
|
25
24
|
<link rel="icon" type="image/svg" href="/studio/favicon.svg" />
|
|
26
25
|
|
|
@@ -28,8 +28,6 @@ app.get('/slug', async (c) => {
|
|
|
28
28
|
return c.json({ status: 404, message: 'No title to generate slug from' })
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
console.log('slug', slugify(title))
|
|
32
|
-
|
|
33
31
|
return streamSSE(c, async (stream) => {
|
|
34
32
|
await stream.writeSSE({
|
|
35
33
|
event: 'datastar-patch-signals',
|
|
@@ -64,16 +62,20 @@ export default (props: {
|
|
|
64
62
|
|
|
65
63
|
if (!data) return html`<p>No block</p>`
|
|
66
64
|
|
|
65
|
+
const entry = query.root({ id: entryId })
|
|
66
|
+
const title = entry?.fields?.title?.value
|
|
67
|
+
const sluggedTitle = slugify(title)
|
|
68
|
+
|
|
67
69
|
return html`
|
|
68
|
-
<div
|
|
69
|
-
style="display: flex; align-items: center"
|
|
70
|
-
data-signals="{ slug: '${data.value}' }"
|
|
71
|
-
>
|
|
70
|
+
<div style="display: flex; align-items: center">
|
|
72
71
|
<form
|
|
73
|
-
data-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
72
|
+
data-on-change="@patch('/studio/api/block', {
|
|
73
|
+
contentType: 'form',
|
|
74
|
+
headers: {
|
|
75
|
+
render: 'Entry',
|
|
76
|
+
props: '${JSON.stringify({ entryId: entryId })}'
|
|
77
|
+
}
|
|
78
|
+
})"
|
|
77
79
|
>
|
|
78
80
|
<hgroup>
|
|
79
81
|
<label for="block-${data.id}">${structure.label}</label>
|
|
@@ -84,7 +86,7 @@ export default (props: {
|
|
|
84
86
|
id="block-${data.id}"
|
|
85
87
|
name="value"
|
|
86
88
|
type="text"
|
|
87
|
-
|
|
89
|
+
value="${data.value}"
|
|
88
90
|
/>
|
|
89
91
|
|
|
90
92
|
<input type="hidden" name="type" value="${structure.type}" />
|
|
@@ -96,9 +98,16 @@ export default (props: {
|
|
|
96
98
|
|
|
97
99
|
<form
|
|
98
100
|
style="margin-top: 21px"
|
|
99
|
-
data-on-submit="@
|
|
101
|
+
data-on-submit="@patch('/studio/api/block', {
|
|
102
|
+
contentType: 'form',
|
|
103
|
+
headers: {
|
|
104
|
+
render: 'Entry',
|
|
105
|
+
props: '${JSON.stringify({ entryId: entryId })}'
|
|
106
|
+
}
|
|
107
|
+
})"
|
|
100
108
|
>
|
|
101
|
-
<input type="hidden" name="
|
|
109
|
+
<input type="hidden" name="id" value="${data.id}" />
|
|
110
|
+
<input type="hidden" name="value" value="${sluggedTitle}" />
|
|
102
111
|
<button
|
|
103
112
|
class="ghost"
|
|
104
113
|
aria-label="Generate slug"
|
|
@@ -12,17 +12,33 @@ export default (props: {
|
|
|
12
12
|
}) => {
|
|
13
13
|
const { entryId, parentId, name, structure, sortOrder = 0, id } = props
|
|
14
14
|
|
|
15
|
-
const data = getOrCreateRow({
|
|
15
|
+
const data = getOrCreateRow({
|
|
16
|
+
parentId,
|
|
17
|
+
name,
|
|
18
|
+
field: structure,
|
|
19
|
+
sortOrder,
|
|
20
|
+
id,
|
|
21
|
+
})
|
|
16
22
|
|
|
17
23
|
if (!data) return html`<p>No block</p>`
|
|
18
24
|
|
|
25
|
+
const isEntryTitle = entryId === parentId && name === 'title'
|
|
26
|
+
|
|
19
27
|
return html`
|
|
20
|
-
|
|
21
|
-
data-on-input="@patch('/studio/api/block', {
|
|
28
|
+
<form
|
|
29
|
+
data-on-input="@patch('/studio/api/block', {
|
|
30
|
+
contentType: 'form',
|
|
31
|
+
headers: {
|
|
32
|
+
render: '${isEntryTitle ? 'AdminPanel' : ''}'
|
|
33
|
+
}
|
|
34
|
+
})"
|
|
22
35
|
>
|
|
23
36
|
<hgroup>
|
|
24
37
|
<label for="block-${data.id}">${structure.label}</label>
|
|
25
|
-
|
|
38
|
+
${structure.description &&
|
|
39
|
+
html`
|
|
40
|
+
<p><small>${structure.description}</small></p>
|
|
41
|
+
`}
|
|
26
42
|
</hgroup>
|
|
27
43
|
|
|
28
44
|
<input
|
package/components/icons.ts
CHANGED
|
@@ -230,3 +230,22 @@ export const arrowsRound = html`
|
|
|
230
230
|
/>
|
|
231
231
|
</svg>
|
|
232
232
|
`
|
|
233
|
+
|
|
234
|
+
export const download = html`
|
|
235
|
+
<svg
|
|
236
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
237
|
+
width="16"
|
|
238
|
+
height="16"
|
|
239
|
+
viewBox="0 0 20 20"
|
|
240
|
+
>
|
|
241
|
+
<!-- Icon from HeroIcons by Refactoring UI Inc - https://github.com/tailwindlabs/heroicons/blob/master/LICENSE -->
|
|
242
|
+
<g fill="currentColor">
|
|
243
|
+
<path
|
|
244
|
+
d="M10.75 2.75a.75.75 0 0 0-1.5 0v8.614L6.295 8.235a.75.75 0 1 0-1.09 1.03l4.25 4.5a.75.75 0 0 0 1.09 0l4.25-4.5a.75.75 0 0 0-1.09-1.03l-2.955 3.129z"
|
|
245
|
+
/>
|
|
246
|
+
<path
|
|
247
|
+
d="M3.5 12.75a.75.75 0 0 0-1.5 0v2.5A2.75 2.75 0 0 0 4.75 18h10.5A2.75 2.75 0 0 0 18 15.25v-2.5a.75.75 0 0 0-1.5 0v2.5c0 .69-.56 1.25-1.25 1.25H4.75c-.69 0-1.25-.56-1.25-1.25z"
|
|
248
|
+
/>
|
|
249
|
+
</g>
|
|
250
|
+
</svg>
|
|
251
|
+
`
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { html } from 'hono/html'
|
|
2
|
+
import * as icons from '../icons.ts'
|
|
3
|
+
import { db } from '@alstar/db'
|
|
4
|
+
import { sql } from '../../utils/sql.ts'
|
|
5
|
+
|
|
6
|
+
export default () => {
|
|
7
|
+
const apiKeys = db.database
|
|
8
|
+
.prepare(sql`
|
|
9
|
+
select
|
|
10
|
+
*
|
|
11
|
+
from
|
|
12
|
+
api_keys
|
|
13
|
+
`)
|
|
14
|
+
.all()
|
|
15
|
+
|
|
16
|
+
return html`
|
|
17
|
+
<article data-signals="{ apiKey: '', copied: false }">
|
|
18
|
+
<header>API Keys</header>
|
|
19
|
+
|
|
20
|
+
<table class="striped">
|
|
21
|
+
<thead>
|
|
22
|
+
<tr>
|
|
23
|
+
<th scope="col">Name</th>
|
|
24
|
+
<th scope="col">Value</th>
|
|
25
|
+
<th scope="col">Delete</th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
${apiKeys.map((apiKey) => {
|
|
30
|
+
return html`
|
|
31
|
+
<tr>
|
|
32
|
+
<th scope="row">${apiKey.name}</th>
|
|
33
|
+
<td><input type="text" disabled value="${apiKey.hint}" /></td>
|
|
34
|
+
<td>
|
|
35
|
+
<form
|
|
36
|
+
data-on-submit="@delete('/studio/api/api-key', {
|
|
37
|
+
contentType: 'form',
|
|
38
|
+
headers: {
|
|
39
|
+
render: 'Settings'
|
|
40
|
+
}
|
|
41
|
+
})"
|
|
42
|
+
>
|
|
43
|
+
<button
|
|
44
|
+
data-tooltip="Delete API key"
|
|
45
|
+
data-placement="left"
|
|
46
|
+
type="submit"
|
|
47
|
+
class="ghost"
|
|
48
|
+
>
|
|
49
|
+
${icons.trash}
|
|
50
|
+
</button>
|
|
51
|
+
|
|
52
|
+
<input type="hidden" name="value" value="${apiKey.value}" />
|
|
53
|
+
</form>
|
|
54
|
+
</td>
|
|
55
|
+
</tr>`
|
|
56
|
+
})}
|
|
57
|
+
</tbody>
|
|
58
|
+
</table>
|
|
59
|
+
|
|
60
|
+
<form
|
|
61
|
+
data-on-submit="@post('/studio/api/api-key', {
|
|
62
|
+
contentType: 'form',
|
|
63
|
+
headers: {
|
|
64
|
+
render: 'Settings'
|
|
65
|
+
}
|
|
66
|
+
})"
|
|
67
|
+
>
|
|
68
|
+
<label for="api_key_name"><small>Generate API Key</small></label>
|
|
69
|
+
|
|
70
|
+
<input
|
|
71
|
+
data-bind="name"
|
|
72
|
+
type="text"
|
|
73
|
+
name="name"
|
|
74
|
+
id="api_key_name"
|
|
75
|
+
placeholder="Name"
|
|
76
|
+
/>
|
|
77
|
+
|
|
78
|
+
<button type="submit" class="ghost">Generate key</button>
|
|
79
|
+
</form>
|
|
80
|
+
|
|
81
|
+
<dialog data-attr="{ open: $apiKey !== '' }">
|
|
82
|
+
<article>
|
|
83
|
+
<header>
|
|
84
|
+
<p>API Key</p>
|
|
85
|
+
</header>
|
|
86
|
+
<p>Be sure to save this key, as it wont be shown again.</p>
|
|
87
|
+
|
|
88
|
+
<div style="display: flex; gap: 1rem; align-items: center;">
|
|
89
|
+
<h3 style="margin: 0;">
|
|
90
|
+
<code data-text="$apiKey"></code>
|
|
91
|
+
</h3>
|
|
92
|
+
|
|
93
|
+
<button
|
|
94
|
+
style="display: flex; align-items: center;"
|
|
95
|
+
data-attr="{ id: $apiKey }"
|
|
96
|
+
data-on-click="navigator.clipboard.writeText($apiKey); $copied = true"
|
|
97
|
+
class="ghost"
|
|
98
|
+
aria-label="Copy key to clipboard"
|
|
99
|
+
>
|
|
100
|
+
${icons.clipboard}
|
|
101
|
+
<span
|
|
102
|
+
style="display: none; margin-left: 0.5rem; color: green;"
|
|
103
|
+
data-style="{ display: $copied && 'block' }"
|
|
104
|
+
>
|
|
105
|
+
Copied
|
|
106
|
+
</span>
|
|
107
|
+
</button>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<footer>
|
|
111
|
+
<button
|
|
112
|
+
class="ghost"
|
|
113
|
+
data-on-click="$apiKey = ''; $copied = false; evt.target.closest('dialog')?.close()"
|
|
114
|
+
>
|
|
115
|
+
Close
|
|
116
|
+
</button>
|
|
117
|
+
</footer>
|
|
118
|
+
</article>
|
|
119
|
+
</dialog>
|
|
120
|
+
</article>
|
|
121
|
+
`
|
|
122
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fsp from 'node:fs/promises'
|
|
2
|
+
import { html } from 'hono/html'
|
|
3
|
+
import * as icons from '../icons.ts'
|
|
4
|
+
|
|
5
|
+
export default async () => {
|
|
6
|
+
let backups: string[] = []
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
const backupDir = './backups'
|
|
10
|
+
backups = await fsp.readdir(backupDir)
|
|
11
|
+
} catch (error) {}
|
|
12
|
+
|
|
13
|
+
return html`
|
|
14
|
+
<article data-signals="{ status: null, message: '' }">
|
|
15
|
+
<header>Backup</header>
|
|
16
|
+
|
|
17
|
+
<table class="striped">
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th scope="col">File</th>
|
|
21
|
+
<th scope="col">Download</th>
|
|
22
|
+
<th scope="col">Delete</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
${backups.map(
|
|
27
|
+
(filename) => html`
|
|
28
|
+
<tr>
|
|
29
|
+
<th scope="row">${filename}</th>
|
|
30
|
+
<th>
|
|
31
|
+
<a
|
|
32
|
+
href="/studio/backups/${filename}"
|
|
33
|
+
role="button"
|
|
34
|
+
target="_blank"
|
|
35
|
+
download
|
|
36
|
+
class="ghost square"
|
|
37
|
+
aria-label="Download backup"
|
|
38
|
+
>
|
|
39
|
+
${icons.download}
|
|
40
|
+
</a>
|
|
41
|
+
</th>
|
|
42
|
+
<th>
|
|
43
|
+
<form
|
|
44
|
+
data-on-submit="@delete('/studio/api/backup', {
|
|
45
|
+
contentType: 'form',
|
|
46
|
+
headers: {
|
|
47
|
+
render: 'Settings'
|
|
48
|
+
}
|
|
49
|
+
})"
|
|
50
|
+
>
|
|
51
|
+
<input type="hidden" name="filename" value="${filename}" />
|
|
52
|
+
<button class="ghost square">${icons.trash}</button>
|
|
53
|
+
</form>
|
|
54
|
+
</th>
|
|
55
|
+
</tr>
|
|
56
|
+
`,
|
|
57
|
+
)}
|
|
58
|
+
</tbody>
|
|
59
|
+
</table>
|
|
60
|
+
|
|
61
|
+
<form
|
|
62
|
+
data-on-submit="@post('/studio/api/backup', {
|
|
63
|
+
contentType: 'form',
|
|
64
|
+
headers: {
|
|
65
|
+
render: 'Settings'
|
|
66
|
+
}
|
|
67
|
+
})"
|
|
68
|
+
>
|
|
69
|
+
<button type="submit">Backup database</button>
|
|
70
|
+
</form>
|
|
71
|
+
|
|
72
|
+
<hr />
|
|
73
|
+
|
|
74
|
+
<form
|
|
75
|
+
data-on-submit="@post('/studio/api/backup', { contentType: 'form' })"
|
|
76
|
+
>
|
|
77
|
+
<input type="file" name="file" />
|
|
78
|
+
<button type="submit" class="ghost">Restore database</button>
|
|
79
|
+
<!-- <p
|
|
80
|
+
data-style-color="$status === 200 ? 'green' : 'red'"
|
|
81
|
+
data-text="$message || ' '"
|
|
82
|
+
></p> -->
|
|
83
|
+
</form>
|
|
84
|
+
</article>
|
|
85
|
+
`
|
|
86
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { db } from '@alstar/db'
|
|
2
2
|
import { html } from 'hono/html'
|
|
3
|
-
import { sql } from '
|
|
3
|
+
import { sql } from '../../utils/sql.ts'
|
|
4
4
|
|
|
5
5
|
export default () => {
|
|
6
6
|
const users = db.database
|
|
@@ -15,9 +15,26 @@ export default () => {
|
|
|
15
15
|
return html`
|
|
16
16
|
<article>
|
|
17
17
|
<header>Users</header>
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
|
|
19
|
+
<table class="striped">
|
|
20
|
+
<thead>
|
|
21
|
+
<tr>
|
|
22
|
+
<th scope="col">Email</th>
|
|
23
|
+
</tr>
|
|
24
|
+
</thead>
|
|
25
|
+
<tbody>
|
|
26
|
+
${users.map(
|
|
27
|
+
(user) => html`
|
|
28
|
+
<tr>
|
|
29
|
+
<th scope="row">${user.email}</th>
|
|
30
|
+
</tr>
|
|
31
|
+
`,
|
|
32
|
+
)}
|
|
33
|
+
</tbody>
|
|
34
|
+
</table>
|
|
35
|
+
|
|
36
|
+
<hr>
|
|
37
|
+
|
|
21
38
|
<article>
|
|
22
39
|
<header>Register user</header>
|
|
23
40
|
<form
|
package/index.ts
CHANGED
|
@@ -31,7 +31,7 @@ export let studioConfig: types.StudioConfig = {
|
|
|
31
31
|
|
|
32
32
|
const createStudio = async (config: types.StudioConfig) => {
|
|
33
33
|
// const refresher = await createRefresher({ rootdir: ['.', import.meta.dirname] })
|
|
34
|
-
const refresher = await createRefresher({ rootdir:
|
|
34
|
+
const refresher = await createRefresher({ rootdir: '.' })
|
|
35
35
|
|
|
36
36
|
loadDb('./studio.db')
|
|
37
37
|
createStudioTables()
|
|
@@ -80,6 +80,8 @@ const createStudio = async (config: types.StudioConfig) => {
|
|
|
80
80
|
*/
|
|
81
81
|
app.notFound((c) => c.html(ErrorPage()))
|
|
82
82
|
app.onError((err, c) => {
|
|
83
|
+
console.log(err)
|
|
84
|
+
|
|
83
85
|
if (err instanceof HTTPException) {
|
|
84
86
|
// Get the custom response
|
|
85
87
|
const error = err.getResponse()
|
|
@@ -89,6 +91,16 @@ const createStudio = async (config: types.StudioConfig) => {
|
|
|
89
91
|
return c.notFound()
|
|
90
92
|
})
|
|
91
93
|
|
|
94
|
+
app.use(
|
|
95
|
+
'/studio/backups/*',
|
|
96
|
+
serveStatic({
|
|
97
|
+
root: './',
|
|
98
|
+
rewriteRequestPath: (path) => path.replace(/^\/studio\/backups/, '/backups'),
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
// console.log(app.routes)
|
|
103
|
+
|
|
92
104
|
/**
|
|
93
105
|
* Run server
|
|
94
106
|
*/
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alstar/studio",
|
|
3
|
-
"version": "0.0.0-beta.
|
|
3
|
+
"version": "0.0.0-beta.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"engines": {
|
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
"@hono/node-server": "^1.18.1",
|
|
11
11
|
"@starfederation/datastar-sdk": "1.0.0-RC.1",
|
|
12
12
|
"hono": "^4.8.12",
|
|
13
|
-
"@alstar/
|
|
13
|
+
"@alstar/refresher": "0.0.0-beta.3",
|
|
14
14
|
"@alstar/ui": "0.0.0-beta.1",
|
|
15
|
-
"@alstar/
|
|
15
|
+
"@alstar/db": "0.0.0-beta.1"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
18
|
"@types/node": "^24.1.0",
|
package/pages/error.ts
CHANGED
package/pages/register.ts
CHANGED
|
@@ -5,7 +5,7 @@ import SiteLayout from '../components/SiteLayout.ts'
|
|
|
5
5
|
export default defineEntry((c) => {
|
|
6
6
|
return SiteLayout(
|
|
7
7
|
html`
|
|
8
|
-
<div class="register-form" style="width: 300px">
|
|
8
|
+
<div class="register-form" style="width: 300px; margin: auto;">
|
|
9
9
|
<article>
|
|
10
10
|
<header>Register user</header>
|
|
11
11
|
<form
|
|
@@ -14,13 +14,14 @@ export default defineEntry((c) => {
|
|
|
14
14
|
data-on-signal-patch="patch.status === 200 && window.location.reload()"
|
|
15
15
|
>
|
|
16
16
|
<label for="email">Email</label>
|
|
17
|
-
<input id="email" name="email" type="text" placeholder="Email" />
|
|
17
|
+
<input id="email" name="email" type="text" placeholder="Email" style="width: 100%" />
|
|
18
18
|
<label for="password">Password</label>
|
|
19
19
|
<input
|
|
20
20
|
id="password"
|
|
21
21
|
name="password"
|
|
22
22
|
type="password"
|
|
23
23
|
placeholder="Password"
|
|
24
|
+
style="width: 100%"
|
|
24
25
|
/>
|
|
25
26
|
<br />
|
|
26
27
|
<button style="width: 100%;">Register</button>
|
|
@@ -1,28 +1,16 @@
|
|
|
1
1
|
#settings {
|
|
2
|
-
|
|
3
|
-
padding: 0;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
li {
|
|
7
|
-
width: 100%;
|
|
8
|
-
display: grid;
|
|
9
|
-
align-items: center;
|
|
10
|
-
grid-template-columns: 15% 1fr auto;
|
|
11
|
-
gap: 1rem;
|
|
12
|
-
border-bottom: 1px solid var(--pico-form-element-border-color);
|
|
13
|
-
margin-bottom: 0;
|
|
14
|
-
padding: 1rem 0;
|
|
15
|
-
|
|
2
|
+
tr {
|
|
16
3
|
input {
|
|
4
|
+
width: 100%;
|
|
17
5
|
font-family: var(--pico-font-family-monospace);
|
|
18
6
|
}
|
|
19
7
|
|
|
20
8
|
* {
|
|
21
|
-
margin
|
|
9
|
+
margin: 0;
|
|
22
10
|
}
|
|
23
11
|
}
|
|
24
12
|
}
|
|
25
13
|
|
|
26
14
|
.login-form {
|
|
27
15
|
width: 100%;
|
|
28
|
-
}
|
|
16
|
+
}
|
package/public/studio/main.css
CHANGED
package/public/studio/main.js
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { stripNewlines } from './strip-newlines.ts'
|
|
3
|
+
import { type SSEStreamingApi } from 'hono/streaming'
|
|
4
|
+
import { type Context } from 'hono'
|
|
5
|
+
|
|
6
|
+
export const renderSSE = async (stream: SSEStreamingApi, c: Context) => {
|
|
7
|
+
const componentPath = c.req.header('render')
|
|
8
|
+
const props = c.req.header('props')
|
|
9
|
+
|
|
10
|
+
if (componentPath) {
|
|
11
|
+
try {
|
|
12
|
+
const partialToRender = await import(
|
|
13
|
+
path.join('../', 'components', componentPath + '.ts')
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const propsJSON = props ? JSON.parse(props) : undefined
|
|
17
|
+
const component = await partialToRender.default(propsJSON)
|
|
18
|
+
|
|
19
|
+
await stream.writeSSE({
|
|
20
|
+
event: 'datastar-patch-elements',
|
|
21
|
+
data: `elements ${stripNewlines(component)}`,
|
|
22
|
+
})
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.log(error)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
package/utils/slugify.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export function slugify(str
|
|
1
|
+
export function slugify(str?: string | null) {
|
|
2
|
+
if (!str) return ''
|
|
3
|
+
|
|
2
4
|
return String(str)
|
|
3
5
|
.normalize('NFKD') // split accented characters into their base characters and diacritical marks
|
|
4
6
|
.replace(/[\u0300-\u036f]/g, '') // remove all the accents, which happen to be all in the \u03xx UNICODE block.
|
package/components/Backup.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { html } from 'hono/html'
|
|
2
|
-
|
|
3
|
-
export default () => {
|
|
4
|
-
return html`<article data-signals="{ status: null, message: '' }">
|
|
5
|
-
<header>Backup</header>
|
|
6
|
-
<form
|
|
7
|
-
data-on-submit="@post('/studio/api/backup', { contentType: 'form' })"
|
|
8
|
-
>
|
|
9
|
-
<button type="submit">Backup database</button>
|
|
10
|
-
<p data-style-color="$status === 200 ? 'green' : 'red'" data-text="$message || ' '"></p>
|
|
11
|
-
</form>
|
|
12
|
-
</article>`
|
|
13
|
-
}
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
import { type Block } from '../types.ts'
|
|
2
|
-
import { structure } from '../index.ts'
|
|
3
|
-
|
|
4
|
-
export type StructurePath = (string | number)[]
|
|
5
|
-
|
|
6
|
-
function getTargetPath(
|
|
7
|
-
target: Block,
|
|
8
|
-
path: StructurePath,
|
|
9
|
-
): number | string | undefined {
|
|
10
|
-
if (!path.length) {
|
|
11
|
-
return structure.findIndex((block) => block.name === target.name)
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
let sub = structure
|
|
15
|
-
|
|
16
|
-
path.forEach((key: number | string) => {
|
|
17
|
-
if (sub) {
|
|
18
|
-
// @ts-ignore
|
|
19
|
-
sub = sub[key]
|
|
20
|
-
}
|
|
21
|
-
})
|
|
22
|
-
|
|
23
|
-
if (Array.isArray(sub)) {
|
|
24
|
-
return sub.findIndex((block) => block.name === target.name)
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
return
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export const buildStructurePath = (
|
|
31
|
-
target: Block | undefined,
|
|
32
|
-
path: StructurePath = [],
|
|
33
|
-
) => {
|
|
34
|
-
if (!target) return path
|
|
35
|
-
|
|
36
|
-
const targetPlacement = getTargetPath(target, path)
|
|
37
|
-
|
|
38
|
-
if (targetPlacement !== undefined) {
|
|
39
|
-
path.push(targetPlacement)
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
return path
|
|
43
|
-
}
|