@alstar/studio 0.0.0-beta.10 → 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.
Files changed (48) hide show
  1. package/api/api-key.ts +37 -49
  2. package/api/auth.ts +64 -0
  3. package/api/backup.ts +39 -28
  4. package/api/block.ts +80 -119
  5. package/api/index.ts +17 -10
  6. package/api/mcp.ts +39 -42
  7. package/components/AdminPanel.ts +29 -4
  8. package/components/BlockFieldRenderer.ts +20 -4
  9. package/components/Entries.ts +7 -3
  10. package/components/FieldRenderer.ts +1 -1
  11. package/components/Settings.ts +5 -93
  12. package/components/SiteLayout.ts +11 -9
  13. package/components/fields/Markdown.ts +1 -1
  14. package/components/fields/Slug.ts +122 -0
  15. package/components/fields/Text.ts +20 -4
  16. package/components/fields/index.ts +4 -1
  17. package/components/icons.ts +55 -0
  18. package/components/settings/ApiKeys.ts +122 -0
  19. package/components/settings/Backup.ts +82 -0
  20. package/components/settings/Users.ts +63 -0
  21. package/index.ts +51 -11
  22. package/package.json +6 -3
  23. package/pages/entry/[id].ts +1 -3
  24. package/pages/error.ts +14 -0
  25. package/pages/index.ts +1 -1
  26. package/pages/login.ts +21 -0
  27. package/pages/register.ts +33 -0
  28. package/pages/settings.ts +1 -3
  29. package/public/studio/css/settings.css +7 -15
  30. package/public/studio/js/markdown-editor.js +1 -1
  31. package/public/studio/js/sortable-list.js +1 -1
  32. package/public/studio/main.css +5 -1
  33. package/public/studio/main.js +13 -0
  34. package/queries/block-2.ts +339 -0
  35. package/queries/getBlockTrees-2.ts +71 -0
  36. package/queries/index.ts +1 -1
  37. package/readme.md +2 -2
  38. package/schema.sql +18 -0
  39. package/schemas.ts +11 -1
  40. package/types.ts +11 -0
  41. package/utils/auth.ts +54 -0
  42. package/utils/create-hash.ts +9 -0
  43. package/utils/define.ts +1 -3
  44. package/utils/renderSSE.ts +27 -0
  45. package/utils/slugify.ts +3 -1
  46. package/utils/startup-log.ts +15 -6
  47. package/components/Backup.ts +0 -10
  48. package/utils/build-structure-path.ts +0 -43
@@ -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
+ }
@@ -0,0 +1,63 @@
1
+ import { db } from '@alstar/db'
2
+ import { html } from 'hono/html'
3
+ import { sql } from '../../utils/sql.ts'
4
+
5
+ export default () => {
6
+ const users = db.database
7
+ .prepare(sql`
8
+ select
9
+ email
10
+ from
11
+ users
12
+ `)
13
+ .all()
14
+
15
+ return html`
16
+ <article>
17
+ <header>Users</header>
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
+
38
+ <article>
39
+ <header>Register user</header>
40
+ <form
41
+ data-on-submit="@post('/studio/api/auth/register', { contentType: 'form' })"
42
+ >
43
+ <label for="register_email"><small>Email</small></label>
44
+ <input
45
+ id="register_email"
46
+ name="email"
47
+ type="email"
48
+ placeholder="Email"
49
+ />
50
+ <label for="register_password"><small>Password</small></label>
51
+ <input
52
+ id="register_password"
53
+ name="password"
54
+ type="password"
55
+ placeholder="Password"
56
+ />
57
+ <br />
58
+ <button type="submit" class="ghost">Create</button>
59
+ </form>
60
+ </article>
61
+ </article>
62
+ `
63
+ }
package/index.ts CHANGED
@@ -1,17 +1,24 @@
1
+ import path from 'node:path'
2
+
1
3
  import { Hono } from 'hono'
2
- import { loadDb } from '@alstar/db'
3
4
  import { serve } from '@hono/node-server'
4
5
  import { serveStatic } from '@hono/node-server/serve-static'
6
+ import { HTTPException } from 'hono/http-exception'
7
+
8
+ import { loadDb } from '@alstar/db'
5
9
  import { createRefresher } from '@alstar/refresher'
6
10
 
7
- import * as types from './types.ts'
8
11
  import { createStudioTables } from './utils/create-studio-tables.ts'
9
12
  import { fileBasedRouter } from './utils/file-based-router.ts'
10
13
  import { getConfig } from './utils/get-config.ts'
11
14
  import startupLog from './utils/startup-log.ts'
12
- import { api } from './api/index.ts'
13
- import mcp from './api/mcp.ts'
14
- import path from 'path'
15
+ import { apiRoutes } from './api/index.ts'
16
+ import { mcpRoutes } from './api/mcp.ts'
17
+
18
+ import auth from './utils/auth.ts'
19
+ import ErrorPage from './pages/error.ts'
20
+
21
+ import * as types from './types.ts'
15
22
 
16
23
  export let rootdir = './node_modules/@alstar/studio'
17
24
 
@@ -23,6 +30,7 @@ export let studioConfig: types.StudioConfig = {
23
30
  }
24
31
 
25
32
  const createStudio = async (config: types.StudioConfig) => {
33
+ // const refresher = await createRefresher({ rootdir: ['.', import.meta.dirname] })
26
34
  const refresher = await createRefresher({ rootdir: '.' })
27
35
 
28
36
  loadDb('./studio.db')
@@ -44,26 +52,58 @@ const createStudio = async (config: types.StudioConfig) => {
44
52
  app.use('*', serveStatic({ root: path.join(rootdir, 'public') }))
45
53
  app.use('*', serveStatic({ root: './public' }))
46
54
 
55
+ /**
56
+ * Require authentication to access Studio
57
+ */
58
+ app.use('/studio/*', auth)
59
+
47
60
  /**
48
61
  * Studio API routes
49
62
  */
50
- app.route('/admin/api', api(studioStructure))
51
- app.route('/admin/mcp', mcp())
63
+ app.route('/studio/api', apiRoutes)
64
+ app.route('/studio/mcp', mcpRoutes)
52
65
 
53
66
  /**
54
67
  * Studio pages
55
68
  */
56
- const adminPages = await fileBasedRouter(path.join(rootdir, 'pages'))
57
-
58
- if (adminPages) app.route('/admin', adminPages)
69
+ const studioPages = await fileBasedRouter(path.join(rootdir, 'pages'))
70
+ if (studioPages) app.route('/studio', studioPages)
59
71
 
60
72
  /**
61
73
  * User pages
62
74
  */
63
75
  const pages = await fileBasedRouter('./pages')
64
-
65
76
  if (pages) app.route('/', pages)
66
77
 
78
+ /**
79
+ * Error pages
80
+ */
81
+ app.notFound((c) => c.html(ErrorPage()))
82
+ app.onError((err, c) => {
83
+ console.log(err)
84
+
85
+ if (err instanceof HTTPException) {
86
+ // Get the custom response
87
+ const error = err.getResponse()
88
+ return c.html(ErrorPage(err))
89
+ }
90
+
91
+ return c.notFound()
92
+ })
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
+
104
+ /**
105
+ * Run server
106
+ */
67
107
  const server = serve({
68
108
  fetch: app.fetch,
69
109
  port: studioConfig.port,
package/package.json CHANGED
@@ -1,15 +1,18 @@
1
1
  {
2
2
  "name": "@alstar/studio",
3
- "version": "0.0.0-beta.10",
3
+ "version": "0.0.0-beta.12",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
+ "engines": {
7
+ "node": ">=23.8"
8
+ },
6
9
  "dependencies": {
7
10
  "@hono/node-server": "^1.18.1",
8
11
  "@starfederation/datastar-sdk": "1.0.0-RC.1",
9
12
  "hono": "^4.8.12",
13
+ "@alstar/db": "0.0.0-beta.1",
10
14
  "@alstar/refresher": "0.0.0-beta.3",
11
- "@alstar/ui": "0.0.0-beta.1",
12
- "@alstar/db": "0.0.0-beta.1"
15
+ "@alstar/ui": "0.0.0-beta.1"
13
16
  },
14
17
  "devDependencies": {
15
18
  "@types/node": "^24.1.0",
@@ -11,7 +11,5 @@ export default defineEntry((c) => {
11
11
  return html`<p>Entry page url needs an ID param: "${id}"</p>`
12
12
  }
13
13
 
14
- return SiteLayout({
15
- content: Entry({ entryId: parseInt(id) }),
16
- })
14
+ return SiteLayout(Entry({ entryId: parseInt(id) }))
17
15
  })
package/pages/error.ts ADDED
@@ -0,0 +1,14 @@
1
+ import { html } from "hono/html";
2
+ import SiteLayout from "../components/SiteLayout.ts";
3
+ import type { HTTPResponseError } from "hono/types";
4
+
5
+ export default ((err?: Error | HTTPResponseError) => {
6
+
7
+ return SiteLayout(html`
8
+ <article>
9
+ <header>Something went wrong</header>
10
+ <p>Try again</p>
11
+ <p>${err?.message || '404 - Not found'}</p>
12
+ </article>
13
+ `, false)
14
+ })
package/pages/index.ts CHANGED
@@ -29,5 +29,5 @@ export default defineEntry(() => {
29
29
  </article>
30
30
  </div>`
31
31
 
32
- return SiteLayout({ content: !Object.values(studioStructure).length ? Discamer : '' })
32
+ return SiteLayout(!Object.values(studioStructure).length ? Discamer : '')
33
33
  })
package/pages/login.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { html } from "hono/html";
2
+ import { defineEntry } from "../utils/define.ts";
3
+ import SiteLayout from "../components/SiteLayout.ts";
4
+
5
+ export default defineEntry(c => {
6
+ return SiteLayout(html`
7
+ <div class="login-form">
8
+ <article>
9
+ <header>Login</header>
10
+ <form data-on-submit="@post('/studio/api/auth/login', { contentType: 'form' })">
11
+ <label for="email">Email</label>
12
+ <input id="email" name="email" type="text" placeholder="Email">
13
+ <label for="password">Password</label>
14
+ <input id="password" name="password" type="password" placeholder="Password">
15
+ <br>
16
+ <button style="width: 100%;">Login</button>
17
+ </form>
18
+ </article>
19
+ </div>
20
+ `, false)
21
+ })
@@ -0,0 +1,33 @@
1
+ import { html } from 'hono/html'
2
+ import { defineEntry } from '../utils/define.ts'
3
+ import SiteLayout from '../components/SiteLayout.ts'
4
+
5
+ export default defineEntry((c) => {
6
+ return SiteLayout(
7
+ html`
8
+ <div class="register-form" style="width: 300px">
9
+ <article>
10
+ <header>Register user</header>
11
+ <form
12
+ data-signals="{ status: 0 }"
13
+ data-on-submit="@post('/studio/api/auth/register', { contentType: 'form' })"
14
+ data-on-signal-patch="patch.status === 200 && window.location.reload()"
15
+ >
16
+ <label for="email">Email</label>
17
+ <input id="email" name="email" type="text" placeholder="Email" />
18
+ <label for="password">Password</label>
19
+ <input
20
+ id="password"
21
+ name="password"
22
+ type="password"
23
+ placeholder="Password"
24
+ />
25
+ <br />
26
+ <button style="width: 100%;">Register</button>
27
+ </form>
28
+ </article>
29
+ </div>
30
+ `,
31
+ false,
32
+ )
33
+ })
package/pages/settings.ts CHANGED
@@ -4,7 +4,5 @@ import SiteLayout from '../components/SiteLayout.ts'
4
4
  import Settings from '../components/Settings.ts'
5
5
 
6
6
  export default defineEntry(() => {
7
- return SiteLayout({
8
- content: Settings(),
9
- })
7
+ return SiteLayout(Settings())
10
8
  })
@@ -1,24 +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
  }
13
+
14
+ .login-form {
15
+ width: 100%;
16
+ }
@@ -9,7 +9,7 @@ class MarkdownEditor extends HTMLElement {
9
9
  this.instance = ink(this, {
10
10
  hooks: {
11
11
  afterUpdate: async (e) => {
12
- await fetch('/admin/api/value', {
12
+ await fetch('/studio/api/value', {
13
13
  method: 'PATCH',
14
14
  body: JSON.stringify({
15
15
  value: e,
@@ -22,7 +22,7 @@ class SortableList extends HTMLElement {
22
22
  searchParams.set('id', child.dataset.id)
23
23
  searchParams.set('sort-order', idx)
24
24
 
25
- await fetch(`/admin/api/sort-order?${searchParams.toString()}`, {
25
+ await fetch(`/studio/api/sort-order?${searchParams.toString()}`, {
26
26
  method: 'post',
27
27
  })
28
28
  })
@@ -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';
@@ -16,6 +15,7 @@ body {
16
15
  padding: 40px;
17
16
  height: 100vh;
18
17
  overflow: auto;
18
+ width: 100%;
19
19
 
20
20
  section {
21
21
  max-width: 900px;
@@ -24,6 +24,10 @@ body {
24
24
  }
25
25
  }
26
26
 
27
+ button svg, a svg {
28
+ pointer-events: none;
29
+ }
30
+
27
31
  .entry > .fields > .block {
28
32
  padding-block: var(--pico-spacing);
29
33
 
@@ -4,6 +4,19 @@ 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
  })
11
+
12
+ // client side script
13
+ // const eventSource = new EventSource('http://localhost:5432')
14
+ // eventSource.onmessage = ({ data }) => {
15
+ // const delay = data.split(' - ').at(-1) === 'true'
16
+ // setTimeout(() => window.location.reload(), delay ? 0 : 1000)
17
+ // }
18
+
19
+ // console.log(
20
+ // '%c REFRESHER ACTIVE ',
21
+ // 'color: green; background: lightgreen; border-radius: 2px'
22
+ // )