@alstar/studio 0.0.0-beta.11 → 0.0.0-beta.12

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 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.writeSSE({
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.writeSSE({
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
@@ -59,8 +59,6 @@ app.post('/login', async (c) => {
59
59
  setCookie(c, 'login', 'yes')
60
60
 
61
61
  return c.redirect('/studio')
62
-
63
- // return c.json({ status: 200, message: 'Logged in!' })
64
62
  })
65
63
 
66
64
  export const authRoutes = app
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
- console.log('Backup')
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
- await stream.writeSSE({
33
- event: 'datastar-patch-signals',
34
- data: `signals { status: 500, message: '${name} failed' }`,
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 { studioStructure } from '../index.ts'
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 row = studioStructure[data.name?.toString()]
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
- if (!row) return
31
+ db.insertInto('blocks', definedData)
28
32
 
29
- db.insertInto('blocks', {
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
- if (entryId === parentId && name?.toString() === 'title') {
86
- const rootBlock = query.block({
87
- id: parentId?.toString() || null,
88
- })
89
-
90
- if (rootBlock.type !== 'single') {
91
- await stream.writeSSE({
92
- event: 'datastar-patch-elements',
93
- data: `elements <a href="/studio/entry/${entryId}" id="block_link_${entryId}">${value}</a>`,
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
- const transaction = db.database.prepare(deleteBlockWithChildren)
126
-
127
- transaction.all(id)
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
 
@@ -37,10 +37,20 @@ export default () => {
37
37
 
38
38
  return html`
39
39
  <form
40
- data-on-submit="@post('/studio/api/block', { contentType: 'form' })"
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/new-block', { contentType: 'form' })"
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, idx) => {
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', { contentType: 'form' })"
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>
@@ -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', { contentType: 'form' })"
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
@@ -1,104 +1,13 @@
1
- import { db } from '@alstar/db'
2
1
  import { html } from 'hono/html'
3
- import { sql } from '../utils/sql.ts'
4
- import * as icons from './icons.ts'
5
- import Backup from './Backup.ts'
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" data-signals="{ apiKey: '', copied: false }">
8
+ <div id="settings">
20
9
  <!-- <h1>Settings</h1> -->
21
- <article>
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
  }
@@ -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-ref="form"
74
- data-on-input="@patch('/studio/api/block', { contentType: 'form' })"
75
- data-on-submit="@patch('/studio/api/block', { contentType: 'form' })"
76
- data-on-signal-patch="$form.requestSubmit()"
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
- data-bind="slug"
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="@get('/studio/api/field/slug', { contentType: 'form' })"
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="entryId" value="${entryId}" />
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({ parentId, name, field: structure, sortOrder, id })
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
- <form
21
- data-on-input="@patch('/studio/api/block', { contentType: 'form' })"
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
- <p><small>${structure.description}</small></p>
38
+ ${structure.description &&
39
+ html`
40
+ <p><small>${structure.description}</small></p>
41
+ `}
26
42
  </hgroup>
27
43
 
28
44
  <input
@@ -1,6 +1,6 @@
1
1
  import Text from './Text.ts'
2
2
  import Markdown from './Markdown.ts'
3
- import Slug, { routes as slugRoutes } from './slug.ts'
3
+ import Slug, { routes as slugRoutes } from './Slug.ts'
4
4
 
5
5
  export const Field = { Text, Markdown, Slug }
6
6
 
@@ -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,82 @@
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
+ const backupDir = './backups'
7
+ const backups = await fsp.readdir(backupDir)
8
+
9
+ return html`
10
+ <article data-signals="{ status: null, message: '' }">
11
+ <header>Backup</header>
12
+
13
+ <table class="striped">
14
+ <thead>
15
+ <tr>
16
+ <th scope="col">File</th>
17
+ <th scope="col">Download</th>
18
+ <th scope="col">Delete</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ ${backups.map(
23
+ (filename) => html`
24
+ <tr>
25
+ <th scope="row">${filename}</th>
26
+ <th>
27
+ <a
28
+ href="/studio/backups/${filename}"
29
+ role="button"
30
+ target="_blank"
31
+ download
32
+ class="ghost square"
33
+ aria-label="Download backup"
34
+ >
35
+ ${icons.download}
36
+ </a>
37
+ </th>
38
+ <th>
39
+ <form
40
+ data-on-submit="@delete('/studio/api/backup', {
41
+ contentType: 'form',
42
+ headers: {
43
+ render: 'Settings'
44
+ }
45
+ })"
46
+ >
47
+ <input type="hidden" name="filename" value="${filename}" />
48
+ <button class="ghost square">${icons.trash}</button>
49
+ </form>
50
+ </th>
51
+ </tr>
52
+ `,
53
+ )}
54
+ </tbody>
55
+ </table>
56
+
57
+ <form
58
+ data-on-submit="@post('/studio/api/backup', {
59
+ contentType: 'form',
60
+ headers: {
61
+ render: 'Settings'
62
+ }
63
+ })"
64
+ >
65
+ <button type="submit">Backup database</button>
66
+ </form>
67
+
68
+ <hr>
69
+
70
+ <form
71
+ data-on-submit="@post('/studio/api/backup', { contentType: 'form' })"
72
+ >
73
+ <input type="file" name="file" />
74
+ <button type="submit" class="ghost">Restore database</button>
75
+ <!-- <p
76
+ data-style-color="$status === 200 ? 'green' : 'red'"
77
+ data-text="$message || '&nbsp;'"
78
+ ></p> -->
79
+ </form>
80
+ </article>
81
+ `
82
+ }
@@ -1,6 +1,6 @@
1
1
  import { db } from '@alstar/db'
2
2
  import { html } from 'hono/html'
3
- import { sql } from '../utils/sql.ts'
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
- <ul>
19
- ${users.map(user => html`<li>${user.email}</li>`)}
20
- </ul>
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.11",
3
+ "version": "0.0.0-beta.12",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
6
  "engines": {
@@ -11,8 +11,8 @@
11
11
  "@starfederation/datastar-sdk": "1.0.0-RC.1",
12
12
  "hono": "^4.8.12",
13
13
  "@alstar/db": "0.0.0-beta.1",
14
- "@alstar/ui": "0.0.0-beta.1",
15
- "@alstar/refresher": "0.0.0-beta.3"
14
+ "@alstar/refresher": "0.0.0-beta.3",
15
+ "@alstar/ui": "0.0.0-beta.1"
16
16
  },
17
17
  "devDependencies": {
18
18
  "@types/node": "^24.1.0",
package/pages/error.ts CHANGED
@@ -8,7 +8,7 @@ export default ((err?: Error | HTTPResponseError) => {
8
8
  <article>
9
9
  <header>Something went wrong</header>
10
10
  <p>Try again</p>
11
- <p>${err?.message}</p>
11
+ <p>${err?.message || '404 - Not found'}</p>
12
12
  </article>
13
13
  `, false)
14
14
  })
@@ -1,28 +1,16 @@
1
1
  #settings {
2
- ul {
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-bottom: 0;
9
+ margin: 0;
22
10
  }
23
11
  }
24
12
  }
25
13
 
26
14
  .login-form {
27
15
  width: 100%;
28
- }
16
+ }
@@ -1,4 +1,3 @@
1
- /* @import './../node_modules/@alstar/ui/red.css'; */
2
1
  @import 'https://esm.sh/@alstar/ui/red.css';
3
2
  @import './css/admin-panel.css';
4
3
  @import './css/blocks-field.css';
@@ -4,6 +4,7 @@ import './js/markdown-editor.js'
4
4
  import './js/sortable-list.js'
5
5
 
6
6
  barba.init({
7
+ debug: true,
7
8
  cacheIgnore: true,
8
9
  views: [{ namespace: 'default' }],
9
10
  })
@@ -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: string) {
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.
@@ -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 || '&nbsp;'"></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
- }