create-wirejs-app 2.0.93 → 2.0.95-async-api
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/package.json +1 -1
- package/templates/default/api/apps/chat.ts +39 -8
- package/templates/default/package.json +3 -3
- package/templates/default/src/components/account-menu.ts +18 -0
- package/templates/default/src/components/index.ts +1 -0
- package/templates/default/src/layouts/main.ts +157 -0
- package/templates/default/src/package.json +10 -0
- package/templates/default/src/ssg/index.ts +17 -0
- package/templates/default/src/ssg/realtime-test.ts +121 -0
- package/templates/default/src/ssg/todo-app.ts +67 -0
- package/templates/default/src/ssr/simple-wiki/%.ts +93 -0
- package/templates/default/apps/chat.ts +0 -27
- package/templates/default/apps/todos.ts +0 -97
- package/templates/default/apps/wiki.ts +0 -25
- package/templates/default/index.ts +0 -13
package/package.json
CHANGED
|
@@ -1,16 +1,33 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AuthenticationApi,
|
|
3
|
+
BackgroundJob,
|
|
4
|
+
RealtimeService,
|
|
5
|
+
withContext,
|
|
6
|
+
} from "wirejs-resources";
|
|
2
7
|
|
|
3
8
|
const realtimeService = new RealtimeService<{
|
|
4
9
|
username: string;
|
|
5
10
|
body: string;
|
|
6
11
|
}>('app', 'realtime');
|
|
7
12
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
13
|
+
const counter = new BackgroundJob('app', 'countdowns', {
|
|
14
|
+
handler: async (room: string, seconds: number) => {
|
|
15
|
+
return new Promise<void>((resolve) => {
|
|
16
|
+
let remaining = seconds;
|
|
17
|
+
const interval = setInterval(() => {
|
|
18
|
+
if (remaining <= 0) {
|
|
19
|
+
clearInterval(interval);
|
|
20
|
+
resolve();
|
|
21
|
+
} else {
|
|
22
|
+
realtimeService.publish(sanitizedRoomName(room), [{
|
|
23
|
+
username: 'Countdown',
|
|
24
|
+
body: `Time remaining: ${remaining--} seconds`
|
|
25
|
+
}]);
|
|
26
|
+
}
|
|
27
|
+
}, 1000);
|
|
28
|
+
});
|
|
29
|
+
},
|
|
30
|
+
});
|
|
14
31
|
|
|
15
32
|
export const Chat = (auth: AuthenticationApi) => withContext(context => ({
|
|
16
33
|
async publish(room: string, message: string) {
|
|
@@ -23,5 +40,19 @@ export const Chat = (auth: AuthenticationApi) => withContext(context => ({
|
|
|
23
40
|
async getRoom(room: string) {
|
|
24
41
|
await auth.requireCurrentUser(context);
|
|
25
42
|
return realtimeService.getStream(sanitizedRoomName(room));
|
|
43
|
+
},
|
|
44
|
+
async startCountdown(room: string, seconds: number) {
|
|
45
|
+
await auth.requireCurrentUser(context);
|
|
46
|
+
if (seconds < 5 || seconds > 600) {
|
|
47
|
+
throw new Error('Countdown must be between 5 and 60 seconds.');
|
|
48
|
+
}
|
|
49
|
+
await counter.start(sanitizedRoomName(room), seconds);
|
|
26
50
|
}
|
|
27
|
-
}));
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
function sanitizedRoomName(room: string | null): string {
|
|
54
|
+
if (room === null || room === '') {
|
|
55
|
+
return 'default';
|
|
56
|
+
}
|
|
57
|
+
return room.replace(/[^-_a-zA-Z0-9]/g, '-').slice(0, 50);
|
|
58
|
+
}
|
|
@@ -11,11 +11,11 @@
|
|
|
11
11
|
"dompurify": "^3.2.3",
|
|
12
12
|
"marked": "^15.0.6",
|
|
13
13
|
"wirejs-dom": "^1.0.41",
|
|
14
|
-
"wirejs-resources": "^0.1.
|
|
15
|
-
"wirejs-components": "^0.1.
|
|
14
|
+
"wirejs-resources": "^0.1.90-async-api",
|
|
15
|
+
"wirejs-components": "^0.1.33-async-api"
|
|
16
16
|
},
|
|
17
17
|
"devDependencies": {
|
|
18
|
-
"wirejs-scripts": "^3.0.
|
|
18
|
+
"wirejs-scripts": "^3.0.88-async-api",
|
|
19
19
|
"typescript": "^5.7.3"
|
|
20
20
|
},
|
|
21
21
|
"scripts": {
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { html } from "wirejs-dom/v2";
|
|
2
|
+
import { AccountMenu as BaseAccountMenu } from "wirejs-components";
|
|
3
|
+
|
|
4
|
+
type BaseOptions = Omit<Parameters<typeof BaseAccountMenu>[0], 'topItems' | 'bottomItems'>;
|
|
5
|
+
|
|
6
|
+
export function AccountMenu(options: BaseOptions) {
|
|
7
|
+
return BaseAccountMenu({
|
|
8
|
+
...options,
|
|
9
|
+
topItems: [
|
|
10
|
+
(state) => state?.state !== 'authenticated'
|
|
11
|
+
? html`<div><i>Sign in to get started!</i></div>`
|
|
12
|
+
: html`<div>Welcome!</div>`
|
|
13
|
+
],
|
|
14
|
+
bottomItems: [
|
|
15
|
+
html`<div>Made with wirejs</div>`
|
|
16
|
+
]
|
|
17
|
+
});
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { AccountMenu } from './account-menu.js';
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { html, node, hydrate } from 'wirejs-dom/v2';
|
|
2
|
+
import { auth } from 'my-api';
|
|
3
|
+
import { AccountMenu } from '../components/index.js';
|
|
4
|
+
import type { AuthenticationState } from 'wirejs-resources';
|
|
5
|
+
|
|
6
|
+
const TITLE = 'My New Site';
|
|
7
|
+
const SUBTITLE = 'Made with wirejs';
|
|
8
|
+
const MENU_ID = 'account-menu';
|
|
9
|
+
const DISCLAIMER = html`<div>
|
|
10
|
+
<p>For the purposes of awesomeness only.</p>
|
|
11
|
+
</div>`;
|
|
12
|
+
|
|
13
|
+
async function Account() {
|
|
14
|
+
return html`<div id='${MENU_ID}'>
|
|
15
|
+
${AccountMenu({ api: auth })}
|
|
16
|
+
</div>`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function Main(slots: {
|
|
20
|
+
/**
|
|
21
|
+
* Replaces the default prefix in the final page title.
|
|
22
|
+
*/
|
|
23
|
+
siteTitle?:
|
|
24
|
+
| string
|
|
25
|
+
| ((state: AuthenticationState) => string);
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Appears on the page under the site title.
|
|
29
|
+
*
|
|
30
|
+
* Set to empty-string explicitly to omit the default.
|
|
31
|
+
*/
|
|
32
|
+
siteSubTitle?:
|
|
33
|
+
| string
|
|
34
|
+
| ((state: AuthenticationState) => string);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The page title. Appears below the site title and subtitle when given.
|
|
38
|
+
*/
|
|
39
|
+
pageTitle?:
|
|
40
|
+
| string
|
|
41
|
+
| ((state: AuthenticationState) => string);
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Author for this page. Appears next to the title in lighter font and
|
|
45
|
+
* in the address bar.
|
|
46
|
+
*/
|
|
47
|
+
pageAuthor?:
|
|
48
|
+
| string
|
|
49
|
+
| ((state: AuthenticationState) => string);
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The main content for the page.
|
|
53
|
+
*/
|
|
54
|
+
content:
|
|
55
|
+
| HTMLElement
|
|
56
|
+
| ((state: AuthenticationState) => HTMLElement);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Appears in the top of the footer.
|
|
60
|
+
*/
|
|
61
|
+
disclaimer?:
|
|
62
|
+
| string
|
|
63
|
+
| HTMLElement
|
|
64
|
+
| ((state: AuthenticationState) => string | HTMLElement);
|
|
65
|
+
}) {
|
|
66
|
+
|
|
67
|
+
const pageAuthorElement = slots.pageAuthor ? html`
|
|
68
|
+
<span style='color: var(--color-muted);'>
|
|
69
|
+
: according to <a href='/wiki/view/~${slots.pageAuthor}'>${slots.pageAuthor}</a>
|
|
70
|
+
</span>
|
|
71
|
+
` : '';
|
|
72
|
+
|
|
73
|
+
const pageTitle = slots.pageTitle ? html`
|
|
74
|
+
<h2 style='font-variant: small-caps;'>
|
|
75
|
+
${slots.pageTitle} ${pageAuthorElement}
|
|
76
|
+
</h2>
|
|
77
|
+
` : '';
|
|
78
|
+
|
|
79
|
+
const browserBarTitle = [
|
|
80
|
+
slots.pageTitle,
|
|
81
|
+
slots.pageAuthor,
|
|
82
|
+
slots.siteTitle || TITLE,
|
|
83
|
+
slots.siteSubTitle || SUBTITLE,
|
|
84
|
+
].filter(Boolean).slice(0, 3).join(' - ');
|
|
85
|
+
|
|
86
|
+
const page = html`
|
|
87
|
+
<!doctype html>
|
|
88
|
+
<html id='root'>
|
|
89
|
+
<head>
|
|
90
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
91
|
+
<title>${browserBarTitle}</title>
|
|
92
|
+
<link rel='icon' type='image/svg+xml' href='/images/logo.svg' />
|
|
93
|
+
<link rel='stylesheet' type='text/css' href='/default.css' />
|
|
94
|
+
</head>
|
|
95
|
+
<body>
|
|
96
|
+
<div style='
|
|
97
|
+
border-width: 0 1px;
|
|
98
|
+
border-color: silver;
|
|
99
|
+
border-style: solid;
|
|
100
|
+
max-width: 1200px;
|
|
101
|
+
min-height: 100vh;
|
|
102
|
+
padding: 0 1rem;
|
|
103
|
+
margin: 0 auto;
|
|
104
|
+
overflow: hidden;
|
|
105
|
+
'>
|
|
106
|
+
<div style='
|
|
107
|
+
display: flex;
|
|
108
|
+
width: 100%;
|
|
109
|
+
padding-bottom: 0.5rem;
|
|
110
|
+
border-bottom: 1px solid var(--border-color-muted, #777);
|
|
111
|
+
margin-bottom: 1rem;
|
|
112
|
+
'>
|
|
113
|
+
<!-- source: https://www.svgrepo.com/svg/322460/greek-temple -->
|
|
114
|
+
<svg
|
|
115
|
+
style='
|
|
116
|
+
margin-top: 1.4rem;
|
|
117
|
+
margin-right: 0.5rem;
|
|
118
|
+
'
|
|
119
|
+
height='4rem'
|
|
120
|
+
viewBox="0 0 512 512"
|
|
121
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
122
|
+
><path fill="#000000" d="M256 26.2L52 135h408L256 26.2zM73 153v14h366v-14H73zm16 32v206h30V185H89zm101.334 0v206h30V185h-30zm101.332 0v206h30V185h-30zM393 185v206h30V185h-30zM73 409v30h366v-30H73zm-32 48v30h430v-30H41z"/></svg>
|
|
123
|
+
|
|
124
|
+
<div style='flex-basis: 0; flex-grow: 5;'>
|
|
125
|
+
<h1 style='font-variant: small-caps; text-shadow: silver 2px 2px 2px;'>
|
|
126
|
+
<a href='/' style='color: var(--color-strong, #000);'>${
|
|
127
|
+
slots.siteTitle || TITLE
|
|
128
|
+
}</a>
|
|
129
|
+
</h1>
|
|
130
|
+
|
|
131
|
+
<div style='
|
|
132
|
+
margin-top: -1rem;
|
|
133
|
+
color: var(--color-muted, #888);
|
|
134
|
+
'>${slots.siteSubTitle ?? SUBTITLE}</div>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<div style='margin-top: 3.5rem; flex-grow: 0;'>
|
|
138
|
+
${node('account', await Account())}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
${pageTitle}
|
|
143
|
+
|
|
144
|
+
<div id='content'>${slots.content}</div>
|
|
145
|
+
|
|
146
|
+
<footer>
|
|
147
|
+
${slots.disclaimer ?? DISCLAIMER}
|
|
148
|
+
</footer>
|
|
149
|
+
</div>
|
|
150
|
+
</body>
|
|
151
|
+
</html>
|
|
152
|
+
`;
|
|
153
|
+
return page;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// TODO: fix wirejs `hydrate()` type
|
|
157
|
+
hydrate(MENU_ID, Account as any);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { html } from 'wirejs-dom/v2';
|
|
2
|
+
import { Main } from '../layouts/main.js';
|
|
3
|
+
|
|
4
|
+
export async function generate() {
|
|
5
|
+
return Main({
|
|
6
|
+
pageTitle: 'Welcome!',
|
|
7
|
+
content: html`<div>
|
|
8
|
+
<p>This is your wirejs app!</p>
|
|
9
|
+
<p>It comes with some sample API methods and pages.</p>
|
|
10
|
+
<ul>
|
|
11
|
+
<li><a href='/todo-app.html'>Todo App</a></li>
|
|
12
|
+
<li><a href='/simple-wiki/index.html'>Simple Wiki</a></li>
|
|
13
|
+
<li><a href='/realtime-test.html'>Realtime Test</a></li>
|
|
14
|
+
</ul>
|
|
15
|
+
</div>`
|
|
16
|
+
})
|
|
17
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import DOMPurify from 'dompurify';
|
|
3
|
+
import { html, attribute, hydrate, list, text, id } from 'wirejs-dom/v2';
|
|
4
|
+
import { AuthenticatedContent } from 'wirejs-components';
|
|
5
|
+
import { Main } from '../layouts/main.js';
|
|
6
|
+
import { chat } from 'my-api';
|
|
7
|
+
|
|
8
|
+
type RoomMessage = {
|
|
9
|
+
username: string;
|
|
10
|
+
body: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
async function Chat() {
|
|
14
|
+
const self = html`<div id='chat'>
|
|
15
|
+
<!-- All messages. Markdown formatted. Sanitized. -->
|
|
16
|
+
${list('messages', (message: RoomMessage) =>
|
|
17
|
+
html`${DOMPurify.sanitize((
|
|
18
|
+
marked.parse(`**${message.username}:** ${message.body}`
|
|
19
|
+
) as string))}`)}
|
|
20
|
+
|
|
21
|
+
<!-- New message form -->
|
|
22
|
+
<form onsubmit=${(event: Event) => {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
chat.publish(null, self.data.room, self.data.message);
|
|
25
|
+
self.data.message = '';
|
|
26
|
+
}}>
|
|
27
|
+
<input type='text' value=${attribute('message', '' as string)} />
|
|
28
|
+
<input type='submit' value='Send' />
|
|
29
|
+
</form>
|
|
30
|
+
|
|
31
|
+
<!-- Connection status -->
|
|
32
|
+
<span style='color: var(--color-muted)'>${text('status', 'Connecting ...')}</span>
|
|
33
|
+
|
|
34
|
+
<!-- Room selection form -->
|
|
35
|
+
<form ${id('roomChangeForm')} onsubmit=${(event: Event) => {
|
|
36
|
+
event.preventDefault();
|
|
37
|
+
self.data.messages = [];
|
|
38
|
+
self.disconnect();
|
|
39
|
+
self.data.status = `Connecting to "${self.data.room}" ...`;
|
|
40
|
+
self.connect();
|
|
41
|
+
}}>Join another room:
|
|
42
|
+
<input type='text' style='width: 10rem;'
|
|
43
|
+
value=${attribute('room', 'test' as string)} />
|
|
44
|
+
<input type='submit' style='width: 10rem;' value='Join' />
|
|
45
|
+
<input type='button' style='width: 10rem;' value='Random' onclick=${() => {
|
|
46
|
+
const randomRoom = Math.random().toString(36).substring(2, 10);
|
|
47
|
+
self.data.room = randomRoom;
|
|
48
|
+
self.data.roomChangeForm.dispatchEvent(new Event('submit'));
|
|
49
|
+
}} />
|
|
50
|
+
</form>
|
|
51
|
+
|
|
52
|
+
<!-- Countdown form -->
|
|
53
|
+
<form ${id('countdownForm')} onsubmit=${(event: Event) => {
|
|
54
|
+
event.preventDefault();
|
|
55
|
+
if (self.data.countdownDisabled) {
|
|
56
|
+
// discourage spamming the countdown
|
|
57
|
+
alert('Countdown is already running. One at a time please!');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
self.data.countdownDisabled = true;
|
|
61
|
+
chat.startCountdown(null, self.data.room, self.data.countdownSeconds);
|
|
62
|
+
self.data.countdownSeconds = 10;
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
self.data.countdownDisabled = false;
|
|
65
|
+
}, self.data.countdownSeconds * 1000);
|
|
66
|
+
}}>
|
|
67
|
+
<input type='hidden' value=${attribute('countdownDisabled', false as boolean)} />
|
|
68
|
+
<input type='number' style='width: 10rem;'
|
|
69
|
+
value=${attribute('countdownSeconds', 10 as number)}
|
|
70
|
+
min='5' max='60' />
|
|
71
|
+
<input type='submit' style='width: 10rem;' value='Start Counting' />
|
|
72
|
+
</form>
|
|
73
|
+
|
|
74
|
+
<!-- Description -->
|
|
75
|
+
<p>A simple example of realtime messaging. Messages are 100% ephemeral. If you reload the page, messages are lost. If you're not connected when a message it sent, you won't receive it.</p>
|
|
76
|
+
<p>The countdown feature is a simple background job that sends a message every second until the countdown reaches zero.</p>
|
|
77
|
+
</div>`.extend(() => ({
|
|
78
|
+
disconnect() {
|
|
79
|
+
// no implementation until connected
|
|
80
|
+
},
|
|
81
|
+
async connect() {
|
|
82
|
+
const roomStream = await chat.getRoom(null, self.data.room);
|
|
83
|
+
self.disconnect = roomStream.subscribe({
|
|
84
|
+
onopen() {
|
|
85
|
+
self.data.status = `Connected to "${self.data.room}".`;
|
|
86
|
+
},
|
|
87
|
+
onmessage(message) {
|
|
88
|
+
self.data.messages.push(message);
|
|
89
|
+
},
|
|
90
|
+
onclose(reason) {
|
|
91
|
+
if (reason !== 'unsubscribed') {
|
|
92
|
+
self.data.status = 'Disconnected. (Refresh the page to reconnect.)';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}))
|
|
98
|
+
.onadd(async () => {
|
|
99
|
+
self.connect();
|
|
100
|
+
});
|
|
101
|
+
return self;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function App() {
|
|
105
|
+
return html`<div id='app'>
|
|
106
|
+
<h4>Realtime Demo</h4>
|
|
107
|
+
${await AuthenticatedContent({
|
|
108
|
+
authenticated: Chat,
|
|
109
|
+
unauthenticated: () => html`<p>Sign in for the realtime demo.</p>`
|
|
110
|
+
})}
|
|
111
|
+
</div>`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function generate() {
|
|
115
|
+
return Main({
|
|
116
|
+
pageTitle: 'Welcome!',
|
|
117
|
+
content: await App()
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
hydrate('app', App as any);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { html, list, attribute, hydrate } from 'wirejs-dom/v2';
|
|
2
|
+
import { AuthenticatedContent } from 'wirejs-components';
|
|
3
|
+
import { todos, Todo } from 'my-api';
|
|
4
|
+
import { Main } from '../layouts/main.js';
|
|
5
|
+
|
|
6
|
+
function Todos() {
|
|
7
|
+
const remove = (todo: Todo) => {
|
|
8
|
+
self.data.todos = self.data.todos.filter(t => t.id !== todo.id);
|
|
9
|
+
todos.remove(null, todo.id);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const newid = () => crypto.randomUUID();
|
|
13
|
+
|
|
14
|
+
const self = html`<div>
|
|
15
|
+
<h4>Your Todos</h4>
|
|
16
|
+
<ol>${list('todos', (todo: Todo) => html`<li>
|
|
17
|
+
${todo.text} : <span
|
|
18
|
+
style='color: darkred; font-weight: bold; cursor: pointer;'
|
|
19
|
+
onclick=${() => remove(todo)}
|
|
20
|
+
>X</span>
|
|
21
|
+
</li>`)}</ol>
|
|
22
|
+
<div>
|
|
23
|
+
<form onsubmit=${(event: Event) => {
|
|
24
|
+
event.preventDefault();
|
|
25
|
+
const todo = {
|
|
26
|
+
id: newid(),
|
|
27
|
+
list: 'default',
|
|
28
|
+
text: self.data.newTodoText,
|
|
29
|
+
order: (self.data.todos[
|
|
30
|
+
self.data.todos.length - 1
|
|
31
|
+
]?.order ?? 0) + 1,
|
|
32
|
+
};
|
|
33
|
+
self.data.todos.push(todo);
|
|
34
|
+
self.data.newTodoText = '';
|
|
35
|
+
todos.save(null, todo).catch((e: any) => alert(e.message));
|
|
36
|
+
}}>
|
|
37
|
+
<input type='text' value=${attribute('newTodoText', '' as string)} />
|
|
38
|
+
<input type='submit' value='Add' />
|
|
39
|
+
</form>
|
|
40
|
+
</div>
|
|
41
|
+
<div>`.onadd(async self => {
|
|
42
|
+
self.data.todos = await todos.read(null, 'default');
|
|
43
|
+
});
|
|
44
|
+
return self;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function App() {
|
|
48
|
+
const self = html`<div id='app'>
|
|
49
|
+
${await AuthenticatedContent({
|
|
50
|
+
authenticated: () => Todos(),
|
|
51
|
+
unauthenticated: () => html`<div>
|
|
52
|
+
You need to sign in to add your todo list.
|
|
53
|
+
</div>`
|
|
54
|
+
})}
|
|
55
|
+
</div>`;
|
|
56
|
+
|
|
57
|
+
return self;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function generate() {
|
|
61
|
+
return Main({
|
|
62
|
+
pageTitle: 'Todo App',
|
|
63
|
+
content: await App(),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
hydrate('app', App as any);
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { marked } from 'marked';
|
|
2
|
+
import DOMPurify from 'dompurify';
|
|
3
|
+
import { html, id, hydrate, node, } from 'wirejs-dom/v2';
|
|
4
|
+
import type { AuthenticationMachineState, Context } from 'wirejs-resources';
|
|
5
|
+
import { auth, wiki } from 'my-api';
|
|
6
|
+
import { AuthMonitor } from 'wirejs-components/utils';
|
|
7
|
+
import { Main } from '../../layouts/main.js';
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async function Wiki(init: { context?: Context, data?: any }) {
|
|
11
|
+
const { context, data } = init;
|
|
12
|
+
|
|
13
|
+
const filepath = (context || window).location.pathname;
|
|
14
|
+
|
|
15
|
+
const content: string =
|
|
16
|
+
data?.content ?? await wiki.read(context, filepath);
|
|
17
|
+
|
|
18
|
+
const initialState: AuthenticationMachineState =
|
|
19
|
+
data?.initialAuthState ?? await auth.getState(context);
|
|
20
|
+
|
|
21
|
+
let markdown: string = content ?? `This page doesn't exist yet`;
|
|
22
|
+
const signedOutAction = html`<i>(<b>Sign in</b> to edit.)</i>`;
|
|
23
|
+
const signedInAction = html`<button onclick=${enableEditing}>edit</button>`;
|
|
24
|
+
const invisibleDiv = html`<div style='display: none;'></div>`;
|
|
25
|
+
const editor = html`<div>
|
|
26
|
+
<textarea
|
|
27
|
+
${id('textarea', HTMLTextAreaElement)}
|
|
28
|
+
></textarea>
|
|
29
|
+
</div>`;
|
|
30
|
+
|
|
31
|
+
AuthMonitor.subscribe(state => {
|
|
32
|
+
self.data.actions = actionsFor(state);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
function actionsFor(state: AuthenticationMachineState | undefined) {
|
|
36
|
+
return state?.state === 'authenticated' ? signedInAction : signedOutAction;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function enableEditing() {
|
|
40
|
+
editor.data.textarea.value = markdown;
|
|
41
|
+
self.data.editor = editor;
|
|
42
|
+
self.data.actions = html`<div>
|
|
43
|
+
<button onclick=${submitChanges}>save</button>
|
|
44
|
+
<button onclick=${cancelChanges}>cancel</button>
|
|
45
|
+
</div>`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function submitChanges() {
|
|
49
|
+
markdown = editor.data.textarea.value;
|
|
50
|
+
await wiki.write(context, filepath, markdown);
|
|
51
|
+
self.data.content = markdown;
|
|
52
|
+
self.data.actions = signedInAction;
|
|
53
|
+
self.data.editor = invisibleDiv;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function cancelChanges() {
|
|
57
|
+
self.data.actions = signedInAction;
|
|
58
|
+
self.data.editor = html`<div style='display: none;'></div>`;
|
|
59
|
+
self.data.content = markdown;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const self = html`<div id='wiki'>
|
|
63
|
+
${node('content', markdown, md =>
|
|
64
|
+
html`<div>${DOMPurify.sanitize(marked.parse(md!) as string)}</div>`)
|
|
65
|
+
}
|
|
66
|
+
${node('editor', invisibleDiv)}
|
|
67
|
+
${node('actions', actionsFor(initialState))}
|
|
68
|
+
</div>`.extend(_ => ({
|
|
69
|
+
data: {
|
|
70
|
+
initialAuthState: initialState
|
|
71
|
+
}
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
return self;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function generate(context: Context) {
|
|
78
|
+
const visiblePath = context.location.pathname
|
|
79
|
+
.replaceAll('/', ' > ')
|
|
80
|
+
.replaceAll('<', '<')
|
|
81
|
+
.replaceAll('>', '>')
|
|
82
|
+
.replaceAll('-', ' ')
|
|
83
|
+
.replace(/\s+/g, ' ')
|
|
84
|
+
;
|
|
85
|
+
|
|
86
|
+
return Main({
|
|
87
|
+
siteSubTitle: 'A simple sample wiki',
|
|
88
|
+
pageTitle: visiblePath,
|
|
89
|
+
content: await Wiki({ context }),
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
hydrate('wiki', Wiki);
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { withContext, RealtimeService, AuthenticationApi } from "wirejs-resources";
|
|
2
|
-
|
|
3
|
-
const realtimeService = new RealtimeService<{
|
|
4
|
-
username: string;
|
|
5
|
-
body: string;
|
|
6
|
-
}>('app', 'realtime');
|
|
7
|
-
|
|
8
|
-
function sanitizedRoomName(room: string | null): string {
|
|
9
|
-
if (room === null || room === '') {
|
|
10
|
-
return 'default';
|
|
11
|
-
}
|
|
12
|
-
return room.replace(/[^-_a-zA-Z0-9]/g, '-').slice(0, 50);
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export const Chat = (auth: AuthenticationApi) => withContext(context => ({
|
|
16
|
-
async publish(room: string, message: string) {
|
|
17
|
-
const user = await auth.requireCurrentUser(context);
|
|
18
|
-
return realtimeService.publish(sanitizedRoomName(room), [{
|
|
19
|
-
username: user.displayName,
|
|
20
|
-
body: message
|
|
21
|
-
}]);
|
|
22
|
-
},
|
|
23
|
-
async getRoom(room: string) {
|
|
24
|
-
await auth.requireCurrentUser(context);
|
|
25
|
-
return realtimeService.getStream(sanitizedRoomName(room));
|
|
26
|
-
}
|
|
27
|
-
}));
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
AuthenticationApi,
|
|
3
|
-
DistributedTable,
|
|
4
|
-
PassThruParser,
|
|
5
|
-
withContext
|
|
6
|
-
} from 'wirejs-resources';
|
|
7
|
-
|
|
8
|
-
export type Todo = {
|
|
9
|
-
id: string;
|
|
10
|
-
text: string;
|
|
11
|
-
order: number;
|
|
12
|
-
list: string;
|
|
13
|
-
};
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const userTodos = new DistributedTable('app', 'userTodos', {
|
|
17
|
-
parse: PassThruParser<Todo & { userId: string }>,
|
|
18
|
-
key: {
|
|
19
|
-
partition: { field: 'userId', type: 'string' },
|
|
20
|
-
sort: { field: 'id', type: 'string' }
|
|
21
|
-
},
|
|
22
|
-
indexes: [
|
|
23
|
-
{
|
|
24
|
-
partition: { field: 'userId', type: 'string' },
|
|
25
|
-
sort: { field: 'list', type: 'string' },
|
|
26
|
-
}
|
|
27
|
-
]
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
export const Todos = (auth: AuthenticationApi) => withContext(context => ({
|
|
31
|
-
async read(list?: string): Promise<Todo[]> {
|
|
32
|
-
const user = await auth.requireCurrentUser(context);
|
|
33
|
-
|
|
34
|
-
try {
|
|
35
|
-
const todos = userTodos.query({
|
|
36
|
-
by: 'userId-list',
|
|
37
|
-
where: {
|
|
38
|
-
userId: { eq: user.id },
|
|
39
|
-
list: { eq: list ?? 'default' }
|
|
40
|
-
},
|
|
41
|
-
});
|
|
42
|
-
const todosArray = await fromAsync(todos);
|
|
43
|
-
return todosArray
|
|
44
|
-
.sort((a, b) => a.order - b.order)
|
|
45
|
-
.map(todo => ({
|
|
46
|
-
id: todo.id,
|
|
47
|
-
text: todo.text,
|
|
48
|
-
order: todo.order,
|
|
49
|
-
list: todo.list || 'default'
|
|
50
|
-
}));
|
|
51
|
-
} catch (error) {
|
|
52
|
-
return [];
|
|
53
|
-
}
|
|
54
|
-
},
|
|
55
|
-
|
|
56
|
-
async save(todo: Todo) {
|
|
57
|
-
const user = await auth.requireCurrentUser(context);
|
|
58
|
-
|
|
59
|
-
if (typeof todo.id !== 'string' || typeof todo.text !== 'string') {
|
|
60
|
-
throw new Error("Invalid todo!");
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const finalTodo = {
|
|
64
|
-
userId: user.id,
|
|
65
|
-
id: todo.id,
|
|
66
|
-
text: todo.text,
|
|
67
|
-
order: todo.order,
|
|
68
|
-
list: todo.list || 'default'
|
|
69
|
-
};
|
|
70
|
-
await userTodos.save(finalTodo);
|
|
71
|
-
|
|
72
|
-
return true;
|
|
73
|
-
},
|
|
74
|
-
|
|
75
|
-
async remove(todoId: string) {
|
|
76
|
-
const user = await auth.requireCurrentUser(context);
|
|
77
|
-
|
|
78
|
-
if (typeof todoId !== 'string') {
|
|
79
|
-
throw new Error("Invalid todo ID!");
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
await userTodos.delete({ userId: user.id, id: todoId });
|
|
83
|
-
|
|
84
|
-
return true;
|
|
85
|
-
},
|
|
86
|
-
}));
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* For node 20, which doesn't have `Array.fromAsync()`.
|
|
90
|
-
*/
|
|
91
|
-
async function fromAsync<T>(gen: AsyncGenerator<T>): Promise<T[]> {
|
|
92
|
-
const items: T[] = [];
|
|
93
|
-
for await (const item of gen) {
|
|
94
|
-
items.push(item);
|
|
95
|
-
}
|
|
96
|
-
return items;
|
|
97
|
-
}
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import { withContext, FileService, AuthenticationApi } from "wirejs-resources";
|
|
2
|
-
const wikiPages = new FileService('app', 'wikiPages');
|
|
3
|
-
|
|
4
|
-
function normalizeWikiPageFilename(page: string) {
|
|
5
|
-
return page.replace(/[^-_a-zA-Z0-9/]/g, '-') + '.md';
|
|
6
|
-
}
|
|
7
|
-
|
|
8
|
-
export const Wiki = (auth: AuthenticationApi) => withContext(context => ({
|
|
9
|
-
async read(page: string) {
|
|
10
|
-
const filename = normalizeWikiPageFilename(page);
|
|
11
|
-
try {
|
|
12
|
-
return await wikiPages.read(filename);
|
|
13
|
-
} catch (error) {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
},
|
|
17
|
-
async write(page: string, content: string) {
|
|
18
|
-
await auth.requireCurrentUser(context);
|
|
19
|
-
|
|
20
|
-
const filename = normalizeWikiPageFilename(page);
|
|
21
|
-
await wikiPages.write(filename, content);
|
|
22
|
-
|
|
23
|
-
return true;
|
|
24
|
-
}
|
|
25
|
-
}));
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
import { AuthenticationService} from 'wirejs-resources';
|
|
2
|
-
import { Chat } from './apps/chat.js';
|
|
3
|
-
import { Todos } from './apps/todos.js';
|
|
4
|
-
import { Wiki } from './apps/wiki.js';
|
|
5
|
-
|
|
6
|
-
export type { Todo } from './apps/todos.js';
|
|
7
|
-
|
|
8
|
-
const authService = new AuthenticationService('app', 'core-users');
|
|
9
|
-
|
|
10
|
-
export const auth = authService.buildApi();
|
|
11
|
-
export const chat = Chat(auth);
|
|
12
|
-
export const todos = Todos(auth);
|
|
13
|
-
export const wiki = Wiki(auth);
|