A system for building static webapps
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

docs: update readme

+283 -35
+7 -8
README.md
··· 17 17 18 18 These are our first-party apps, built with Civility. 19 19 20 - | Name | Description | 21 - | --------------------------------------------- | --------------------------------------------------------- | 22 - | [CookApp](https://git.sr.ht/~bpev/cook) | Store Cooking Recipes | 23 - | [ClimbApp](https://git.sr.ht/~bpev/climb) | An app for logging Climbing Sessions | 24 - | [FitApp](https://git.sr.ht/~bpev/fit) | Log Workouts | 25 - | [HanziApp](https://git.sr.ht/~bpev/hanzi) | Learn Chinese and Japanese Characters | 26 - | [JournalApp](https://git.sr.ht/~bpev/journal) | A Daily Journal | 27 - | [MapsApp](https://git.sr.ht/~bpev/maps) | An offline-capable maps app for logging where you've been | 20 + | Name | Description | 21 + | --------------------------------------------------------------------------------- | --------------------------------------------------------- | 22 + | [CookApp](https://cook.bpev.me) ([source](https://git.sr.ht/~bpev/cook)) | Store Cooking Recipes | 23 + | [ClimbApp](https://climb.bpev.me) ([source](https://tangled.org/bpev.me/climb)) | An app for logging Climbing Sessions | 24 + | [HanziApp](https://hanzi.bpev.me) ([source](https://tangled.org/bpev.me/hanzi)) | Learn Chinese and Japanese Characters | 25 + | [JournalApp](https://journal.bpev.me) ([source](https://git.sr.ht/~bpev/journal)) | A Daily Journal | 26 + | [MapsApp](https://maps.bpev.me) ([source](https://tangled.org/bpev.me/maps)) | An offline-capable maps app for logging where you've been |
+138
docs/how_to/add_email_provider.md
··· 1 + # Adding an Email Provider to a cServer 2 + 3 + Civility uses an `EmailAdapter` to send password reset and email verification links. Without one, links are logged to the server console instead — a workable fallback for self-hosted deployments with a trusted user base. 4 + 5 + --- 6 + 7 + ## 1. The interface 8 + 9 + ```ts 10 + interface EmailAdapter { 11 + send(opts: { 12 + to: string 13 + subject: string 14 + html: string 15 + text?: string 16 + }): Promise<void> 17 + } 18 + ``` 19 + 20 + Implement this interface for any provider and pass it to `createServer`. 21 + 22 + --- 23 + 24 + ## 2. Wire it up 25 + 26 + ```ts 27 + // server.ts 28 + import { createServer } from '@civility/hono' 29 + 30 + const kv = await Deno.openKv() 31 + 32 + const app = await createServer({ 33 + kv, 34 + email: myEmailAdapter, // see examples below 35 + }) 36 + 37 + Deno.serve(app.fetch) 38 + ``` 39 + 40 + --- 41 + 42 + ## 3. Provider examples 43 + 44 + ### Resend 45 + 46 + ```ts 47 + import type { EmailAdapter } from '@civility/hono' 48 + 49 + function createResendAdapter(apiKey: string): EmailAdapter { 50 + return { 51 + async send({ to, subject, html, text }) { 52 + const res = await fetch('https://api.resend.com/emails', { 53 + method: 'POST', 54 + headers: { 55 + 'Authorization': `Bearer ${apiKey}`, 56 + 'Content-Type': 'application/json', 57 + }, 58 + body: JSON.stringify({ 59 + from: 'Civility <noreply@yourdomain.com>', 60 + to, 61 + subject, 62 + html, 63 + text, 64 + }), 65 + }) 66 + if (!res.ok) { 67 + throw new Error(`Resend error: ${res.status} ${await res.text()}`) 68 + } 69 + }, 70 + } 71 + } 72 + 73 + // Usage: 74 + const app = await createServer({ 75 + kv, 76 + email: createResendAdapter(Deno.env.get('RESEND_API_KEY')!), 77 + }) 78 + ``` 79 + 80 + ### Postmark 81 + 82 + ```ts 83 + import type { EmailAdapter } from '@civility/hono' 84 + 85 + function createPostmarkAdapter(serverToken: string): EmailAdapter { 86 + return { 87 + async send({ to, subject, html, text }) { 88 + const res = await fetch('https://api.postmarkapp.com/email', { 89 + method: 'POST', 90 + headers: { 91 + 'Accept': 'application/json', 92 + 'Content-Type': 'application/json', 93 + 'X-Postmark-Server-Token': serverToken, 94 + }, 95 + body: JSON.stringify({ 96 + From: 'noreply@yourdomain.com', 97 + To: to, 98 + Subject: subject, 99 + HtmlBody: html, 100 + TextBody: text, 101 + }), 102 + }) 103 + if (!res.ok) { 104 + throw new Error(`Postmark error: ${res.status} ${await res.text()}`) 105 + } 106 + }, 107 + } 108 + } 109 + ``` 110 + 111 + ### SMTP (via a relay) 112 + 113 + If your host provides SMTP, use Deno's `nodemailer` compat or a fetch-based SMTP relay. For most production deployments, a transactional email API (Resend, Postmark, SES) is simpler than raw SMTP. 114 + 115 + --- 116 + 117 + ## 4. Self-hosted / no email configured 118 + 119 + When no `email` adapter is passed to `createServer`, reset and verification links are printed to stdout: 120 + 121 + ``` 122 + [civility] Password reset link for user@example.com: https://your-server.com/reset-password?token=... 123 + [civility] Email verification link for user@example.com: https://your-server.com/verify-email?token=... 124 + ``` 125 + 126 + The server operator can copy the link from logs and send it to the user out-of-band (Signal, email, etc.). Reset tokens expire after **1 hour**; verification tokens expire after **24 hours**. 127 + 128 + --- 129 + 130 + ## 5. Behaviour by feature 131 + 132 + | Feature | With adapter | Without adapter | 133 + | ----------------------- | --------------------- | ---------------------------- | 134 + | **Password reset** | Email sent to user | Link logged to console | 135 + | **Email verification** | Email sent on signup | Accounts created as verified | 136 + | **Resend verification** | Email sent on request | Link logged to console | 137 + 138 + When no adapter is configured, new accounts are marked as verified automatically so the verification banner never appears.
+79
docs/how_to/integrate_sync.md
··· 1 + # Adding `@civility/sync` to a cApp 2 + 3 + A guide for apps that already use `@civility/store` and want cross-device sync via `@civility/sync`. 4 + 5 + --- 6 + 7 + ## 1. Dependencies (`deno.json`) 8 + 9 + ```json 10 + { 11 + "imports": { 12 + "@civility/blobs": "jsr:@civility/blobs@^1.0.0-beta.1", 13 + "@civility/store": "jsr:@civility/store@^1.0.0-beta.6", 14 + "@civility/store/idb": "jsr:@civility/store@^1.0.0-beta.6/idb", 15 + "@civility/sync": "jsr:@civility/sync@^1.0.0-beta.7", 16 + "@civility/ui": "jsr:@civility/ui@^1.0.0-beta.1", 17 + "@civility/hono": "jsr:@civility/hono@^1.0.0-beta.4" 18 + } 19 + } 20 + ``` 21 + 22 + --- 23 + 24 + ## 2. Expose your Collection from the store 25 + 26 + `Synced` wraps one or more `Collection<T>` instances directly. Expose the collection as a getter: 27 + 28 + ```ts 29 + // models/store.ts 30 + import { Collection } from '@civility/store' 31 + 32 + export class MyStore { 33 + #store = new Collection<MyEntity>(backend, { name: 'my-store', schema }) 34 + 35 + /** Consumed by Synced */ 36 + get syncStore(): Collection<MyEntity> { 37 + return this.#store 38 + } 39 + } 40 + ``` 41 + 42 + The collection `name` becomes the `storeName` key used on the server — keep it stable across releases. 43 + 44 + --- 45 + 46 + ## 3. Wire up `Synced` in your app model 47 + 48 + ```ts 49 + // models/app.ts 50 + import { Synced } from '@civility/sync' 51 + 52 + export class App { 53 + store = new MyStore() 54 + synced = new Synced({ 55 + stores: [this.store.syncStore], 56 + appId: 'my-app', // must match the app ID created in the sync dashboard 57 + }) 58 + 59 + dispose() { 60 + this.synced.dispose() 61 + } 62 + } 63 + ``` 64 + 65 + --- 66 + 67 + ## 4. Add the sync UI to your settings route 68 + 69 + ```ts 70 + // routes/settings.ts 71 + import '@civility/ui' 72 + 73 + // In your template: 74 + html` 75 + <ui-sync storage-key="my-app-sync" .synced="${app.synced}"></ui-sync> 76 + ` 77 + ``` 78 + 79 + `storage-key` namespaces the sync link in `indexedDB` — use a unique value per app so multiple cApps on the same origin don't collide.
+6 -3
packages/hono/auth/adapters/deno-kv.ts
··· 581 581 await emailAdapter.send({ 582 582 to: userEmail, 583 583 subject: 'Reset your password', 584 - html: `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${link}">${link}</a></p><p>If you did not request a password reset, you can ignore this email.</p>`, 585 - text: `Reset your password: ${link}\n\nThis link expires in 1 hour. If you did not request a reset, ignore this email.`, 584 + html: 585 + `<p>Click the link below to reset your password. This link expires in 1 hour.</p><p><a href="${link}">${link}</a></p><p>If you did not request a password reset, you can ignore this email.</p>`, 586 + text: 587 + `Reset your password: ${link}\n\nThis link expires in 1 hour. If you did not request a reset, ignore this email.`, 586 588 }) 587 589 } else { 588 590 console.log(`[civility] Password reset link for ${userEmail}: ${link}`) ··· 600 602 await emailAdapter.send({ 601 603 to: user.email, 602 604 subject: 'Verify your email address', 603 - html: `<p>Click the link below to verify your email address.</p><p><a href="${link}">${link}</a></p>`, 605 + html: 606 + `<p>Click the link below to verify your email address.</p><p><a href="${link}">${link}</a></p>`, 604 607 text: `Verify your email: ${link}`, 605 608 }) 606 609 } else {
+4 -1
packages/hono/auth/router.ts
··· 401 401 if (!ctx || !ctx.user) return err(c, 'auth.unauthorized', 'Unauthorized') 402 402 403 403 const origin = new URL(c.req.url).origin 404 - await handlers.sendVerificationEmail?.(ctx.user.id, `${origin}/verify-email`) 404 + await handlers.sendVerificationEmail?.( 405 + ctx.user.id, 406 + `${origin}/verify-email`, 407 + ) 405 408 406 409 return ok(c, null, 'Verification email sent') 407 410 })
+27 -14
packages/hono/auth/ui_router.ts
··· 61 61 <div class="card" style="border-left: 4px solid #d97706; background: #fffbeb;"> 62 62 <p style="margin: 0;"> 63 63 Please verify your email address (${user.email}). 64 - <form method="POST" action="/api/v1/auth/resend-verification" style="display:inline; margin-left: 0.5rem;"> 65 - <button type="submit" style="background:none;border:none;cursor:pointer;color:#2563eb;text-decoration:underline;padding:0;font-size:inherit;">Resend email</button> 64 + <form 65 + method="POST" 66 + action="/api/v1/auth/resend-verification" 67 + style="display:inline; margin-left: 0.5rem;" 68 + > 69 + <button 70 + type="submit" 71 + style="background:none;border:none;cursor:pointer;color:#2563eb;text-decoration:underline;padding:0;font-size:inherit;" 72 + > 73 + Resend email 74 + </button> 66 75 </form> 67 76 </p> 68 77 </div> ··· 390 399 html` 391 400 <div class="card"> 392 401 <h2>Reset Password</h2> 393 - ${ 394 - success === 'sent' 395 - ? html`<p class="success">If an account exists with that email, a reset link has been sent.</p>` 396 - : null 397 - } 402 + ${success === 'sent' 403 + ? html` 404 + <p class="success"> 405 + If an account exists with that email, a reset link has been sent. 406 + </p> 407 + ` 408 + : null} 398 409 <p style="margin-bottom: 1rem; color: #666;"> 399 410 Enter your email address and we'll send you a link to reset your password. 400 411 </p> ··· 425 436 html` 426 437 <div class="card"> 427 438 <h2>Set New Password</h2> 428 - ${ 429 - error === 'mismatch' 430 - ? html`<p class="error">Passwords do not match.</p>` 431 - : error === 'invalid' 432 - ? html`<p class="error">This reset link is invalid or has expired.</p>` 433 - : null 434 - } 439 + ${error === 'mismatch' 440 + ? html` 441 + <p class="error">Passwords do not match.</p> 442 + ` 443 + : error === 'invalid' 444 + ? html` 445 + <p class="error">This reset link is invalid or has expired.</p> 446 + ` 447 + : null} 435 448 <form method="POST" action="/api/v1/auth/reset-password"> 436 449 <input type="hidden" name="token" value="${token}"> 437 450 <div class="form-group">
-1
packages/hono/deno.json
··· 26 26 "hono": "npm:hono@^4.12.16" 27 27 } 28 28 } 29 -
+22 -8
packages/hono/static/styles.css
··· 19 19 } 20 20 21 21 body { 22 - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 22 + font-family: 23 + -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 23 24 background: var(--bg); 24 25 color: var(--text); 25 26 line-height: 1.5; ··· 127 128 font-family: inherit; 128 129 font-weight: 500; 129 130 cursor: pointer; 130 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.15), 0 1px 0 0 rgba(0, 0, 0, 0.05); 131 + box-shadow: 132 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.15), 133 + 0 1px 0 0 rgba(0, 0, 0, 0.05); 131 134 transition: background 0.1s, box-shadow 0.1s; 132 135 } 133 136 134 137 button:hover { 135 138 background: var(--primary-dark); 136 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.2), 0 2px 1px 0 rgba(0, 0, 0, 0.06); 139 + box-shadow: 140 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.2), 141 + 0 2px 1px 0 rgba(0, 0, 0, 0.06); 137 142 } 138 143 139 144 button:active { ··· 144 149 background: var(--surface); 145 150 border-color: var(--border); 146 151 color: var(--danger); 147 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.06), 0 1px 0 0 rgba(0, 0, 0, 0.03); 152 + box-shadow: 153 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.06), 154 + 0 1px 0 0 rgba(0, 0, 0, 0.03); 148 155 } 149 156 150 157 button.danger:hover { 151 158 background: #fef2f2; 152 159 border-color: #fca5a5; 153 160 color: #b91c1c; 154 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.08), 0 2px 1px 0 rgba(0, 0, 0, 0.04); 161 + box-shadow: 162 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.08), 163 + 0 2px 1px 0 rgba(0, 0, 0, 0.04); 155 164 } 156 165 157 166 button.secondary { 158 167 background: var(--surface); 159 168 border-color: var(--border); 160 169 color: var(--text-muted); 161 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.06), 0 1px 0 0 rgba(0, 0, 0, 0.03); 170 + box-shadow: 171 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.06), 172 + 0 1px 0 0 rgba(0, 0, 0, 0.03); 162 173 } 163 174 164 175 button.secondary:hover { 165 176 background: #f9fafb; 166 177 color: var(--text); 167 - box-shadow: inset 0 -2px 0 0 rgba(0, 0, 0, 0.1), 0 2px 1px 0 rgba(0, 0, 0, 0.04); 178 + box-shadow: 179 + inset 0 -2px 0 0 rgba(0, 0, 0, 0.1), 180 + 0 2px 1px 0 rgba(0, 0, 0, 0.04); 168 181 } 169 182 170 183 .error { ··· 238 251 padding: 0.15rem 0.4rem; 239 252 border-radius: 3px; 240 253 font-size: 0.8125rem; 241 - font-family: IBMPlexMono, ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace; 254 + font-family: 255 + IBMPlexMono, ui-monospace, 'SF Mono', Menlo, Monaco, Consolas, monospace; 242 256 color: #374151; 243 257 } 244 258