create-hyper-effect 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +15 -2
- package/bin.mjs +97 -0
- package/package.json +33 -4
- package/skill/SKILL.md +87 -0
- package/skill/references/api.md +85 -0
- package/skill/references/browser.md +21 -0
- package/skill/references/gotchas.md +46 -0
- package/skill/references/patterns.md +69 -0
- package/template/AGENTS.md +10 -0
- package/template/README.md +39 -0
- package/template/_gitignore +3 -0
- package/template/_package.json +35 -0
- package/template/scripts/check.mjs +76 -0
- package/template/src/app/auth.ts +132 -0
- package/template/src/app/tasks/page.ts +408 -0
- package/template/src/app/tasks/store.ts +81 -0
- package/template/src/lib/button.ts +145 -0
- package/template/src/lib/input.ts +158 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/main.ts +49 -0
- package/template/src/styles.css +128 -0
- package/template/src/ui/button.ts +101 -0
- package/template/src/ui/card.ts +102 -0
- package/template/src/ui/input.ts +38 -0
- package/template/test/tasks.test.ts +89 -0
- package/template/tsconfig.json +21 -0
- package/template/vitest.config.ts +12 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { chromium } from 'playwright'
|
|
2
|
+
|
|
3
|
+
/* Browser check: two people on the task list, in Chromium, against a running
|
|
4
|
+
server (npm start). Extend it with every feature you add; unit tests do
|
|
5
|
+
not see what only a browser does (morphs, focus, typing while the page
|
|
6
|
+
updates).
|
|
7
|
+
|
|
8
|
+
Usage: node scripts/check.mjs (URL=… for another server) */
|
|
9
|
+
|
|
10
|
+
const base = process.env.URL ?? 'http://localhost:4000'
|
|
11
|
+
const results = []
|
|
12
|
+
const check = (name, ok, detail = '') => {
|
|
13
|
+
results.push({ name, ok })
|
|
14
|
+
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
|
|
15
|
+
}
|
|
16
|
+
const until = (page, predicate, arg) =>
|
|
17
|
+
page.waitForFunction(predicate, arg, { timeout: 5000 }).then(() => true, () => false)
|
|
18
|
+
|
|
19
|
+
const browser = await chromium.launch()
|
|
20
|
+
const signIn = async name => {
|
|
21
|
+
const page = await (await browser.newContext()).newPage()
|
|
22
|
+
const errors = []
|
|
23
|
+
page.on('pageerror', error => errors.push(String(error)))
|
|
24
|
+
page.on('console', message => {
|
|
25
|
+
if (message.type() === 'error') errors.push(message.text())
|
|
26
|
+
})
|
|
27
|
+
await page.goto(`${base}/`)
|
|
28
|
+
await page.getByLabel('Your name').fill(name)
|
|
29
|
+
const stream = page.waitForRequest(request => request.url().includes('/stream'))
|
|
30
|
+
await page.getByRole('button', { name: 'Sign in' }).click()
|
|
31
|
+
await stream
|
|
32
|
+
return { page, errors }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const ada = await signIn('Ada')
|
|
37
|
+
const bob = await signIn('Bob')
|
|
38
|
+
check('signing in shows the task list', (await ada.page.locator('#task-list li').count()) >= 1)
|
|
39
|
+
|
|
40
|
+
// Type and press Enter at once: the typed text travels with the submit.
|
|
41
|
+
const title = `Check task ${Date.now() % 10000}`
|
|
42
|
+
await ada.page.getByLabel('New task').pressSequentially(title, { delay: 5 })
|
|
43
|
+
await ada.page.getByLabel('New task').press('Enter')
|
|
44
|
+
check('a task typed and submitted at once is added', await until(ada.page, text => document.querySelector('#task-list')?.textContent?.includes(text), title))
|
|
45
|
+
check('the form is cleared after adding', await until(ada.page, () => document.querySelector('#task-title')?.value === ''))
|
|
46
|
+
check("the other person's page shows it live", await until(bob.page, text => document.querySelector('#task-list')?.textContent?.includes(text), title))
|
|
47
|
+
|
|
48
|
+
// A page-wide shortcut: N focuses the new-task input (a key binding, then
|
|
49
|
+
// a forwarded focus).
|
|
50
|
+
await ada.page.locator('body').click({ position: { x: 5, y: 300 } })
|
|
51
|
+
await ada.page.keyboard.press('n')
|
|
52
|
+
check('N focuses the new-task input', await until(ada.page, () => document.activeElement?.id === 'task-title'))
|
|
53
|
+
await ada.page.keyboard.type('x')
|
|
54
|
+
check('typing there does not trigger the shortcut again', (await ada.page.getByLabel('New task').inputValue()) === 'x')
|
|
55
|
+
await ada.page.getByLabel('New task').fill('')
|
|
56
|
+
|
|
57
|
+
await ada.page.getByRole('button', { name: 'Add', exact: true }).click()
|
|
58
|
+
check('an empty title is refused with a message', await until(ada.page, () => document.querySelector('#new-task')?.textContent?.includes('Describe the task.')))
|
|
59
|
+
|
|
60
|
+
await bob.page.getByRole('button', { name: `Done: ${title}` }).click()
|
|
61
|
+
check('marking it done shows on both pages', await until(ada.page, text => document.querySelector(`[aria-label="Done: ${text}"]`)?.getAttribute('aria-pressed') === 'true', title))
|
|
62
|
+
|
|
63
|
+
await ada.page.getByRole('button', { name: 'Done', exact: true }).click()
|
|
64
|
+
check('the Done filter shows done tasks only', await until(ada.page, () => [...document.querySelectorAll('#task-list li')].every(li => li.textContent.includes('✓'))))
|
|
65
|
+
await ada.page.getByRole('button', { name: 'All', exact: true }).click()
|
|
66
|
+
|
|
67
|
+
await ada.page.getByRole('button', { name: `Remove ${title}` }).click()
|
|
68
|
+
check('removing it removes it everywhere', await until(bob.page, text => !document.querySelector('#task-list')?.textContent?.includes(text), title))
|
|
69
|
+
check('no page errors', ada.errors.length + bob.errors.length === 0, [...ada.errors, ...bob.errors].slice(0, 2).join(' | '))
|
|
70
|
+
} finally {
|
|
71
|
+
await browser.close()
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const failed = results.filter(result => !result.ok).length
|
|
75
|
+
console.log(`\n${results.length - failed}/${results.length} checks passed`)
|
|
76
|
+
process.exit(failed === 0 ? 0 : 1)
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
import { Effect, Layer } from 'effect'
|
|
4
|
+
import { HttpRouter, HttpServerRequest, HttpServerResponse } from 'effect/http'
|
|
5
|
+
|
|
6
|
+
import { PageRedirect, type PageRequest, type RequestContext, secretFromEnvironment } from '@hyper-effect/core'
|
|
7
|
+
|
|
8
|
+
/* Demo sign-in: any name, no password. The name travels in an HMAC-signed,
|
|
9
|
+
HttpOnly cookie:
|
|
10
|
+
- Flags read it (`requireUser`), so a signed-out visit goes to /login;
|
|
11
|
+
- `principal` binds every session to it, so signing out ends the page.
|
|
12
|
+
A real app verifies its own session or token here instead; keep the same
|
|
13
|
+
three exports. */
|
|
14
|
+
|
|
15
|
+
const USER_COOKIE = '{{name}}-user'
|
|
16
|
+
const SECRET = secretFromEnvironment('APP_SECRET', 'dev-secret, set APP_SECRET')
|
|
17
|
+
const MAX_AGE = 60 * 60 * 12
|
|
18
|
+
|
|
19
|
+
const signature = (name: string) => createHmac('sha256', SECRET).update(name).digest()
|
|
20
|
+
|
|
21
|
+
/** The cookie value for a signed-in user (exported for tests). */
|
|
22
|
+
export const cookieValue = (name: string) =>
|
|
23
|
+
`${Buffer.from(name, 'utf8').toString('base64url')}.${signature(name).toString('base64url')}`
|
|
24
|
+
|
|
25
|
+
/** The signed-in user's name, if the cookie is valid. */
|
|
26
|
+
export const signedInUser = (cookies: RequestContext['cookies']): string | undefined => {
|
|
27
|
+
const [encodedName, encodedSignature] = (cookies[USER_COOKIE] ?? '').split('.')
|
|
28
|
+
if (encodedName === undefined || encodedSignature === undefined) return undefined
|
|
29
|
+
const name = Buffer.from(encodedName, 'base64url').toString('utf8')
|
|
30
|
+
const expected = signature(name)
|
|
31
|
+
const actual = Buffer.from(encodedSignature, 'base64url')
|
|
32
|
+
return actual.length === expected.length && timingSafeEqual(actual, expected) ? name : undefined
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** For `Hyper.layer`: sessions belong to the signed-in user. */
|
|
36
|
+
export const principal = ({ cookies }: RequestContext) => Effect.succeed(signedInUser(cookies))
|
|
37
|
+
|
|
38
|
+
/** For Flags: the signed-in user, or a redirect to /login and back. */
|
|
39
|
+
export const requireUser = ({ cookies, url }: PageRequest) => {
|
|
40
|
+
const user = signedInUser(cookies)
|
|
41
|
+
return user === undefined
|
|
42
|
+
? Effect.fail(new PageRedirect({ location: `/login?next=${encodeURIComponent(url.pathname + url.search)}` }))
|
|
43
|
+
: Effect.succeed(user)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const USER_COOKIE_NAME = USER_COOKIE
|
|
47
|
+
|
|
48
|
+
const safeNext = (next: string | null): string =>
|
|
49
|
+
next !== null && next.startsWith('/') && !next.startsWith('//') && !next.startsWith('/\\') ? next : '/'
|
|
50
|
+
|
|
51
|
+
const escapeHtml = (value: string) =>
|
|
52
|
+
value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
|
53
|
+
|
|
54
|
+
const loginPage = (next: string, error: string | undefined) => `<!doctype html>
|
|
55
|
+
<html lang="en">
|
|
56
|
+
<head>
|
|
57
|
+
<meta charset="utf-8">
|
|
58
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
59
|
+
<title>Sign in · {{title}}</title>
|
|
60
|
+
<link rel="stylesheet" href="/styles.css">
|
|
61
|
+
</head>
|
|
62
|
+
<body class="flex min-h-screen items-center justify-center bg-muted/40 p-4">
|
|
63
|
+
<form method="post" action="/login" class="flex w-full max-w-sm flex-col gap-4 rounded-xl border bg-card p-6 text-card-foreground shadow-sm">
|
|
64
|
+
<h1 class="text-lg font-semibold">{{title}}</h1>
|
|
65
|
+
<input type="hidden" name="next" value="${escapeHtml(next)}">
|
|
66
|
+
<label for="name" class="text-sm font-medium">Your name</label>
|
|
67
|
+
<input id="name" name="name" required maxlength="32" autocomplete="username" autofocus class="h-9 rounded-md border bg-transparent px-3 text-sm shadow-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
|
|
68
|
+
${error === undefined ? '' : `<p class="text-sm text-destructive">${escapeHtml(error)}</p>`}
|
|
69
|
+
<button type="submit" class="h-9 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground hover:bg-primary/90">Sign in</button>
|
|
70
|
+
</form>
|
|
71
|
+
</body>
|
|
72
|
+
</html>`
|
|
73
|
+
|
|
74
|
+
const html = (body: string, status = 200) =>
|
|
75
|
+
HttpServerResponse.text(body, {
|
|
76
|
+
status,
|
|
77
|
+
contentType: 'text/html',
|
|
78
|
+
headers: { 'content-security-policy': "default-src 'self'; frame-ancestors 'none'; form-action 'self'" },
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
/** Sign-in and sign-out posts must come from this site. */
|
|
82
|
+
const isSameOrigin = (request: HttpServerRequest.HttpServerRequest) => {
|
|
83
|
+
const origin = request.headers['origin']
|
|
84
|
+
if (origin === undefined) return request.headers['sec-fetch-site'] !== 'cross-site'
|
|
85
|
+
try {
|
|
86
|
+
return new URL(origin).host === request.headers['host']
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const cookie = (request: HttpServerRequest.HttpServerRequest, value: string, maxAge: number) =>
|
|
93
|
+
`${USER_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; SameSite=Lax; HttpOnly${
|
|
94
|
+
request.headers['x-forwarded-proto'] === 'https' ? '; Secure' : ''
|
|
95
|
+
}`
|
|
96
|
+
|
|
97
|
+
export const routes = Layer.mergeAll(
|
|
98
|
+
HttpRouter.add(
|
|
99
|
+
'GET',
|
|
100
|
+
'/login',
|
|
101
|
+
Effect.gen(function* () {
|
|
102
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
103
|
+
return html(loginPage(safeNext(new URL(request.url, 'http://localhost').searchParams.get('next')), undefined))
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
HttpRouter.add(
|
|
107
|
+
'POST',
|
|
108
|
+
'/login',
|
|
109
|
+
Effect.gen(function* () {
|
|
110
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
111
|
+
if (!isSameOrigin(request)) return HttpServerResponse.empty({ status: 403 })
|
|
112
|
+
const form = new URLSearchParams(yield* request.text)
|
|
113
|
+
const name = (form.get('name') ?? '').trim()
|
|
114
|
+
const next = safeNext(form.get('next'))
|
|
115
|
+
if (name.length === 0 || name.length > 32) return html(loginPage(next, 'Enter a name of up to 32 characters.'), 400)
|
|
116
|
+
return HttpServerResponse.redirect(next, { status: 303 }).pipe(
|
|
117
|
+
HttpServerResponse.setHeader('set-cookie', cookie(request, cookieValue(name), MAX_AGE)),
|
|
118
|
+
)
|
|
119
|
+
}).pipe(Effect.catch(() => Effect.succeed(HttpServerResponse.empty({ status: 400 })))),
|
|
120
|
+
),
|
|
121
|
+
HttpRouter.add(
|
|
122
|
+
'POST',
|
|
123
|
+
'/logout',
|
|
124
|
+
Effect.gen(function* () {
|
|
125
|
+
const request = yield* HttpServerRequest.HttpServerRequest
|
|
126
|
+
if (!isSameOrigin(request)) return HttpServerResponse.empty({ status: 403 })
|
|
127
|
+
return HttpServerResponse.redirect('/login', { status: 303 }).pipe(
|
|
128
|
+
HttpServerResponse.setHeader('set-cookie', cookie(request, '', 0)),
|
|
129
|
+
)
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
)
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { Effect, Match as M, Schema as S, SchemaIssue, Stream } from 'effect'
|
|
2
|
+
import { Command, Subscription, type Update } from 'foldkit'
|
|
3
|
+
import * as Dom from 'foldkit/dom'
|
|
4
|
+
import type { Document, Html, HtmlBuilder } from 'foldkit/html'
|
|
5
|
+
import { defineMessageUnion } from 'foldkit/message'
|
|
6
|
+
|
|
7
|
+
import { type Program, keyBindings, memoizeLast, region } from '@hyper-effect/core'
|
|
8
|
+
|
|
9
|
+
import { cn } from '@/lib/utils'
|
|
10
|
+
import { button } from '@/ui/button'
|
|
11
|
+
import { card, cardContent, cardHeader, cardTitle } from '@/ui/card'
|
|
12
|
+
import { input } from '@/ui/input'
|
|
13
|
+
|
|
14
|
+
import { requireUser } from '../auth'
|
|
15
|
+
import { Task, Tasks } from './store'
|
|
16
|
+
|
|
17
|
+
/* The task list: a Foldkit program whose loop runs on the server.
|
|
18
|
+
|
|
19
|
+
- Model, Message, update and view are plain Foldkit; the browser only
|
|
20
|
+
receives morphs and posts events.
|
|
21
|
+
- Every page shows the shared list live, through a Subscription on the
|
|
22
|
+
store's change feed.
|
|
23
|
+
- Regions: each part of the page re-renders and travels only when its
|
|
24
|
+
deps change; a change to one task sends one row. */
|
|
25
|
+
|
|
26
|
+
// MODEL
|
|
27
|
+
|
|
28
|
+
export const Filter = S.Literals(['all', 'open', 'done'])
|
|
29
|
+
export type Filter = typeof Filter.Type
|
|
30
|
+
|
|
31
|
+
export const Model = S.Struct({
|
|
32
|
+
user: S.String,
|
|
33
|
+
tasks: S.Array(Task),
|
|
34
|
+
filter: Filter,
|
|
35
|
+
/** What the user is typing; the input is client-owned (bound with
|
|
36
|
+
* onInput), so the morph never overwrites it while they type. */
|
|
37
|
+
draft: S.String,
|
|
38
|
+
draftError: S.NullOr(S.String),
|
|
39
|
+
isAdding: S.Boolean,
|
|
40
|
+
/** The last change that failed, such as a task someone else deleted. */
|
|
41
|
+
notice: S.NullOr(S.String),
|
|
42
|
+
})
|
|
43
|
+
export type Model = typeof Model.Type
|
|
44
|
+
|
|
45
|
+
// MESSAGE
|
|
46
|
+
|
|
47
|
+
export const Message = defineMessageUnion({
|
|
48
|
+
ChangedDraft: { title: S.String },
|
|
49
|
+
SubmittedDraft: {},
|
|
50
|
+
ClickedToggle: { id: S.String },
|
|
51
|
+
ClickedRemove: { id: S.String },
|
|
52
|
+
ClickedFilter: { filter: Filter },
|
|
53
|
+
ClickedDismissNotice: {},
|
|
54
|
+
PressedNewTask: {},
|
|
55
|
+
CompletedFocus: {},
|
|
56
|
+
GotTasks: { tasks: S.Array(Task) },
|
|
57
|
+
SucceededAdd: {},
|
|
58
|
+
CompletedChange: {},
|
|
59
|
+
FailedChange: { reason: S.String },
|
|
60
|
+
})
|
|
61
|
+
export type Message = typeof Message.Type
|
|
62
|
+
|
|
63
|
+
/** The Messages a browser may post as they are. Typed text arrives through
|
|
64
|
+
* the session's handlers (onInput); store changes and Command results are
|
|
65
|
+
* server facts and cannot be forged. Anything posted is still checked in
|
|
66
|
+
* update: the browser is not trusted. */
|
|
67
|
+
export const ClientMessage = S.Union([
|
|
68
|
+
Message.SubmittedDraft,
|
|
69
|
+
Message.ClickedToggle,
|
|
70
|
+
Message.ClickedRemove,
|
|
71
|
+
Message.ClickedFilter,
|
|
72
|
+
Message.ClickedDismissNotice,
|
|
73
|
+
Message.PressedNewTask,
|
|
74
|
+
])
|
|
75
|
+
export type ClientMessage = typeof ClientMessage.Type
|
|
76
|
+
|
|
77
|
+
// VALIDATION
|
|
78
|
+
|
|
79
|
+
const MAX_TITLE = 120
|
|
80
|
+
|
|
81
|
+
export const Title = S.String.pipe(
|
|
82
|
+
S.check(
|
|
83
|
+
S.isMinLength(1, { message: 'Describe the task.' }),
|
|
84
|
+
S.isMaxLength(MAX_TITLE, { message: `At most ${MAX_TITLE} characters.` }),
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
const decodeTitle = S.decodeUnknownExit(Title)
|
|
88
|
+
const formatIssues = SchemaIssue.makeFormatterStandardSchemaV1()
|
|
89
|
+
|
|
90
|
+
/** The trimmed title, or the message of the first check it fails. */
|
|
91
|
+
export const validateTitle = (draft: string): Readonly<{ title: string } | { error: string }> => {
|
|
92
|
+
const decoded = decodeTitle(draft.trim())
|
|
93
|
+
if (decoded._tag === 'Success') return { title: decoded.value }
|
|
94
|
+
const error = decoded.cause.reasons.find(reason => reason._tag === 'Fail')?.error
|
|
95
|
+
return {
|
|
96
|
+
error: (S.isSchemaError(error) ? formatIssues(error.issue).issues[0]?.message : undefined) ?? 'Check the title.',
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// COMMAND
|
|
101
|
+
|
|
102
|
+
export const AddTask = Command.define('AddTask', {
|
|
103
|
+
args: { title: S.String, by: S.String },
|
|
104
|
+
messages: [Message.SucceededAdd],
|
|
105
|
+
execute: ({ title, by }) =>
|
|
106
|
+
Tasks.use(tasks => tasks.add(title, by)).pipe(Effect.as(Message.SucceededAdd())),
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
export const ToggleTask = Command.define('ToggleTask', {
|
|
110
|
+
args: { id: S.String },
|
|
111
|
+
messages: [Message.CompletedChange, Message.FailedChange],
|
|
112
|
+
execute: ({ id }) =>
|
|
113
|
+
Tasks.use(tasks => tasks.toggle(id)).pipe(
|
|
114
|
+
Effect.as(Message.CompletedChange()),
|
|
115
|
+
Effect.catchTag('TaskNotFound', () => Effect.succeed(Message.FailedChange({ reason: 'That task was just deleted.' }))),
|
|
116
|
+
),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
export const RemoveTask = Command.define('RemoveTask', {
|
|
120
|
+
args: { id: S.String },
|
|
121
|
+
messages: [Message.CompletedChange, Message.FailedChange],
|
|
122
|
+
execute: ({ id }) =>
|
|
123
|
+
Tasks.use(tasks => tasks.remove(id)).pipe(
|
|
124
|
+
Effect.as(Message.CompletedChange()),
|
|
125
|
+
Effect.catchTag('TaskNotFound', () => Effect.succeed(Message.CompletedChange())),
|
|
126
|
+
),
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
/** Runs in the browser after the next morph: forwarded as a focus action
|
|
130
|
+
* (see `clientCommands` in the program). */
|
|
131
|
+
export const FocusNewTask = Command.define('FocusNewTask', {
|
|
132
|
+
messages: [Message.CompletedFocus],
|
|
133
|
+
execute: Dom.focus('#task-title').pipe(Effect.ignore, Effect.as(Message.CompletedFocus())),
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
// INIT
|
|
137
|
+
|
|
138
|
+
export type Flags = Readonly<{ user: string; tasks: ReadonlyArray<Task> }>
|
|
139
|
+
|
|
140
|
+
export const init = (flags: Flags): Update.Return<Model, Message> => ({
|
|
141
|
+
model: {
|
|
142
|
+
user: flags.user,
|
|
143
|
+
tasks: flags.tasks,
|
|
144
|
+
filter: 'all',
|
|
145
|
+
draft: '',
|
|
146
|
+
draftError: null,
|
|
147
|
+
isAdding: false,
|
|
148
|
+
notice: null,
|
|
149
|
+
},
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
// UPDATE
|
|
153
|
+
|
|
154
|
+
type UpdateReturn = Update.Return<Model, Message, Tasks>
|
|
155
|
+
|
|
156
|
+
export const update = (model: Model, message: Message): UpdateReturn =>
|
|
157
|
+
M.value(message).pipe(
|
|
158
|
+
M.withReturnType<UpdateReturn>(),
|
|
159
|
+
M.tagsExhaustive({
|
|
160
|
+
ChangedDraft: ({ title }) => ({ model: { ...model, draft: title, draftError: null } }),
|
|
161
|
+
SubmittedDraft: () => {
|
|
162
|
+
if (model.isAdding) return { model }
|
|
163
|
+
const validated = validateTitle(model.draft)
|
|
164
|
+
return 'error' in validated
|
|
165
|
+
? { model: { ...model, draftError: validated.error } }
|
|
166
|
+
: { model: { ...model, isAdding: true }, commands: [AddTask({ title: validated.title, by: model.user })] }
|
|
167
|
+
},
|
|
168
|
+
// Clearing the draft in the Model clears the input: the server patches
|
|
169
|
+
// a client-owned input when the Model diverges from what it sent.
|
|
170
|
+
SucceededAdd: () => ({ model: { ...model, draft: '', isAdding: false } }),
|
|
171
|
+
ClickedToggle: ({ id }) => ({ model, commands: [ToggleTask({ id })] }),
|
|
172
|
+
ClickedRemove: ({ id }) => ({ model, commands: [RemoveTask({ id })] }),
|
|
173
|
+
ClickedFilter: ({ filter }) => ({ model: { ...model, filter } }),
|
|
174
|
+
ClickedDismissNotice: () => ({ model: { ...model, notice: null } }),
|
|
175
|
+
PressedNewTask: () => ({ model, commands: [FocusNewTask()] }),
|
|
176
|
+
CompletedFocus: () => ({ model }),
|
|
177
|
+
GotTasks: ({ tasks }) => ({ model: { ...model, tasks } }),
|
|
178
|
+
CompletedChange: () => ({ model }),
|
|
179
|
+
FailedChange: ({ reason }) => ({ model: { ...model, notice: reason } }),
|
|
180
|
+
}),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
// SUBSCRIPTION
|
|
184
|
+
|
|
185
|
+
// One Message per change of the list, shared by every session (instead of
|
|
186
|
+
// each session building its own copy).
|
|
187
|
+
const gotTasks = memoizeLast((tasks: ReadonlyArray<Task>) => Message.GotTasks({ tasks }))
|
|
188
|
+
|
|
189
|
+
export const subscriptions = Subscription.make<Model, Message, Tasks>()(entry => ({
|
|
190
|
+
tasks: entry(
|
|
191
|
+
{},
|
|
192
|
+
{
|
|
193
|
+
modelToDependencies: () => ({}),
|
|
194
|
+
dependenciesToStream: () =>
|
|
195
|
+
Stream.unwrap(Tasks.use(tasks => Effect.succeed(tasks.changes.pipe(Stream.map(gotTasks))))),
|
|
196
|
+
},
|
|
197
|
+
),
|
|
198
|
+
}))
|
|
199
|
+
|
|
200
|
+
// VIEW
|
|
201
|
+
|
|
202
|
+
const visibleTasks = (model: Model): ReadonlyArray<Task> =>
|
|
203
|
+
model.filter === 'all' ? model.tasks : model.tasks.filter(task => task.isDone === (model.filter === 'done'))
|
|
204
|
+
|
|
205
|
+
const header = (user: string, h: HtmlBuilder<Message>): Html =>
|
|
206
|
+
h.header(
|
|
207
|
+
[h.Id('page-header'), h.Class('flex items-center justify-between gap-3 border-b bg-background px-4 py-3 sm:px-6')],
|
|
208
|
+
[
|
|
209
|
+
h.span([h.Class('text-base font-semibold')], ['{{title}}']),
|
|
210
|
+
h.div(
|
|
211
|
+
[h.Class('flex items-center gap-2 text-sm text-muted-foreground')],
|
|
212
|
+
[
|
|
213
|
+
h.span([], [user]),
|
|
214
|
+
// A plain form post: signing out is a navigation, not an event.
|
|
215
|
+
h.form(
|
|
216
|
+
[h.Method('post'), h.Action('/logout')],
|
|
217
|
+
[button({ children: ['Sign out'], type: 'submit', variant: 'ghost', size: 'sm' }, h)],
|
|
218
|
+
),
|
|
219
|
+
],
|
|
220
|
+
),
|
|
221
|
+
],
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
const newTaskForm = (model: Model, h: HtmlBuilder<Message>): Html =>
|
|
225
|
+
h.form(
|
|
226
|
+
[h.Id('new-task'), h.Class('flex items-start gap-2'), h.OnSubmit(Message.SubmittedDraft())],
|
|
227
|
+
[
|
|
228
|
+
h.div(
|
|
229
|
+
[h.Class('flex-1')],
|
|
230
|
+
[
|
|
231
|
+
input(
|
|
232
|
+
{
|
|
233
|
+
id: 'task-title',
|
|
234
|
+
ariaLabel: 'New task',
|
|
235
|
+
value: model.draft,
|
|
236
|
+
placeholder: 'What needs doing?',
|
|
237
|
+
onInput: title => Message.ChangedDraft({ title }),
|
|
238
|
+
...(model.draftError === null ? {} : { description: model.draftError, isInvalid: true }),
|
|
239
|
+
},
|
|
240
|
+
h,
|
|
241
|
+
),
|
|
242
|
+
],
|
|
243
|
+
),
|
|
244
|
+
button({ children: ['Add'], type: 'submit', isLoading: model.isAdding }, h),
|
|
245
|
+
],
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
const FILTERS: ReadonlyArray<Readonly<{ value: Filter; label: string }>> = [
|
|
249
|
+
{ value: 'all', label: 'All' },
|
|
250
|
+
{ value: 'open', label: 'Open' },
|
|
251
|
+
{ value: 'done', label: 'Done' },
|
|
252
|
+
]
|
|
253
|
+
|
|
254
|
+
const filters = (model: Model, h: HtmlBuilder<Message>): Html => {
|
|
255
|
+
const open = model.tasks.filter(task => !task.isDone).length
|
|
256
|
+
return h.div(
|
|
257
|
+
[h.Id('filters'), h.Class('flex items-center justify-between gap-2 text-sm')],
|
|
258
|
+
[
|
|
259
|
+
h.div(
|
|
260
|
+
[h.Class('flex gap-1'), h.Role('group'), h.AriaLabel('Show')],
|
|
261
|
+
FILTERS.map(({ value, label }) =>
|
|
262
|
+
button(
|
|
263
|
+
{
|
|
264
|
+
children: [label],
|
|
265
|
+
variant: model.filter === value ? 'secondary' : 'ghost',
|
|
266
|
+
size: 'sm',
|
|
267
|
+
onClick: Message.ClickedFilter({ filter: value }),
|
|
268
|
+
},
|
|
269
|
+
h,
|
|
270
|
+
),
|
|
271
|
+
),
|
|
272
|
+
),
|
|
273
|
+
h.span([h.Class('text-muted-foreground'), h.AriaLive('polite')], [`${open} open of ${model.tasks.length}`]),
|
|
274
|
+
],
|
|
275
|
+
)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const taskRow = (task: Task, h: HtmlBuilder<Message>): Html =>
|
|
279
|
+
h.li(
|
|
280
|
+
[h.Id(`task-${task.id}`), h.Class('flex items-center gap-3 py-2')],
|
|
281
|
+
[
|
|
282
|
+
h.button(
|
|
283
|
+
[
|
|
284
|
+
h.Type('button'),
|
|
285
|
+
h.Class(
|
|
286
|
+
cn(
|
|
287
|
+
'flex size-5 shrink-0 items-center justify-center rounded border text-xs',
|
|
288
|
+
task.isDone && 'border-primary bg-primary text-primary-foreground',
|
|
289
|
+
),
|
|
290
|
+
),
|
|
291
|
+
h.AriaPressed(task.isDone ? 'true' : 'false'),
|
|
292
|
+
h.AriaLabel(`Done: ${task.title}`),
|
|
293
|
+
h.OnClick(Message.ClickedToggle({ id: task.id })),
|
|
294
|
+
],
|
|
295
|
+
[task.isDone ? '✓' : ''],
|
|
296
|
+
),
|
|
297
|
+
h.div(
|
|
298
|
+
[h.Class('min-w-0 flex-1')],
|
|
299
|
+
[
|
|
300
|
+
h.p([h.Class(cn('truncate', task.isDone && 'text-muted-foreground line-through'))], [task.title]),
|
|
301
|
+
h.p([h.Class('text-xs text-muted-foreground')], [`by ${task.createdBy}`]),
|
|
302
|
+
],
|
|
303
|
+
),
|
|
304
|
+
button(
|
|
305
|
+
{ children: ['Remove'], variant: 'ghost', size: 'xs', ariaLabel: `Remove ${task.title}`, onClick: Message.ClickedRemove({ id: task.id }) },
|
|
306
|
+
h,
|
|
307
|
+
),
|
|
308
|
+
],
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
const taskList = (tasks: ReadonlyArray<Task>, h: HtmlBuilder<Message>): Html =>
|
|
312
|
+
tasks.length === 0
|
|
313
|
+
? h.p([h.Id('task-list'), h.Class('py-6 text-center text-sm text-muted-foreground')], ['Nothing here.'])
|
|
314
|
+
: h.ul(
|
|
315
|
+
[h.Id('task-list'), h.Class('divide-y')],
|
|
316
|
+
// A region per task: a change to one task sends only its row.
|
|
317
|
+
tasks.map(task => region(h, { id: `task-${task.id}`, deps: [task], render: () => taskRow(task, h) })),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
const notice = (message: string | null, h: HtmlBuilder<Message>): Html =>
|
|
321
|
+
h.div(
|
|
322
|
+
[h.Id('notice'), h.AriaLive('polite')],
|
|
323
|
+
message === null
|
|
324
|
+
? []
|
|
325
|
+
: [
|
|
326
|
+
h.div(
|
|
327
|
+
[h.Class('flex items-center justify-between gap-2 rounded-md border bg-muted px-3 py-2 text-sm')],
|
|
328
|
+
[
|
|
329
|
+
message,
|
|
330
|
+
button({ children: ['Dismiss'], variant: 'ghost', size: 'xs', onClick: Message.ClickedDismissNotice() }, h),
|
|
331
|
+
],
|
|
332
|
+
),
|
|
333
|
+
],
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
export const view = (model: Model, h: HtmlBuilder<Message>): Document => {
|
|
337
|
+
const visible = visibleTasks(model)
|
|
338
|
+
const open = model.tasks.filter(task => !task.isDone).length
|
|
339
|
+
return {
|
|
340
|
+
title: open === 0 ? '{{title}}' : `(${open}) {{title}}`,
|
|
341
|
+
body: h.div(
|
|
342
|
+
[h.Class('flex min-h-screen flex-col bg-muted/30')],
|
|
343
|
+
[
|
|
344
|
+
// deps list every Model field the region's view reads; outside
|
|
345
|
+
// production a region whose deps miss one fails to render.
|
|
346
|
+
region(h, { id: 'page-header', deps: [model.user], render: () => header(model.user, h) }),
|
|
347
|
+
// Shortcuts for the whole page, matched in the browser: only a
|
|
348
|
+
// matching key posts its Message.
|
|
349
|
+
keyBindings(h, [{ keys: 'N', message: Message.PressedNewTask() }]),
|
|
350
|
+
h.main(
|
|
351
|
+
[h.Class('mx-auto grid w-full max-w-2xl gap-4 p-4 sm:p-6')],
|
|
352
|
+
[
|
|
353
|
+
card(
|
|
354
|
+
{
|
|
355
|
+
children: [
|
|
356
|
+
cardHeader({ children: [cardTitle({ children: ['Tasks'] }, h)] }, h),
|
|
357
|
+
cardContent(
|
|
358
|
+
{
|
|
359
|
+
class: 'grid gap-4',
|
|
360
|
+
children: [
|
|
361
|
+
region(h, {
|
|
362
|
+
id: 'new-task',
|
|
363
|
+
deps: [model.draft, model.draftError, model.isAdding],
|
|
364
|
+
render: () => newTaskForm(model, h),
|
|
365
|
+
}),
|
|
366
|
+
region(h, { id: 'notice', deps: [model.notice], render: () => notice(model.notice, h) }),
|
|
367
|
+
region(h, { id: 'filters', deps: [model.filter, model.tasks], render: () => filters(model, h) }),
|
|
368
|
+
region(h, { id: 'task-list', deps: [visible], render: () => taskList(visible, h) }),
|
|
369
|
+
],
|
|
370
|
+
},
|
|
371
|
+
h,
|
|
372
|
+
),
|
|
373
|
+
],
|
|
374
|
+
},
|
|
375
|
+
h,
|
|
376
|
+
),
|
|
377
|
+
],
|
|
378
|
+
),
|
|
379
|
+
],
|
|
380
|
+
),
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// PROGRAM
|
|
385
|
+
|
|
386
|
+
export const program: Program<Model, Message, ClientMessage, Flags, Tasks> = {
|
|
387
|
+
name: 'tasks',
|
|
388
|
+
Model,
|
|
389
|
+
ClientMessage,
|
|
390
|
+
// Runs per page load: authenticate, then load what the page shows.
|
|
391
|
+
flags: request =>
|
|
392
|
+
Effect.gen(function* () {
|
|
393
|
+
const user = yield* requireUser(request)
|
|
394
|
+
const tasks = yield* Tasks.use(store => store.current)
|
|
395
|
+
return { user, tasks }
|
|
396
|
+
}),
|
|
397
|
+
init,
|
|
398
|
+
update,
|
|
399
|
+
view,
|
|
400
|
+
subscriptions,
|
|
401
|
+
// Snapshots keep only this user's own state; the list comes back from the
|
|
402
|
+
// Subscription after a restore.
|
|
403
|
+
snapshotDeps: model => [model.filter, model.draft, model.draftError, model.notice],
|
|
404
|
+
// Browser-only Commands, performed by the page after the next morph.
|
|
405
|
+
clientCommands: {
|
|
406
|
+
FocusNewTask: () => ({ focus: '#task-title' }),
|
|
407
|
+
},
|
|
408
|
+
}
|