├── .gitignore ├── .prettierrc ├── README.md ├── db └── schema.ts ├── drizzle.config.ts ├── drizzle └── .gitkeep ├── package.json ├── public └── static │ ├── mvp.css │ └── style.css ├── src ├── create.tsx ├── factory.ts ├── index.tsx └── renderer.tsx ├── tsconfig.json ├── vite.config.ts └── wrangler.example.toml /.gitignore: -------------------------------------------------------------------------------- 1 | # prod 2 | dist/ 3 | 4 | wrangler.toml 5 | bun.lockb 6 | 7 | drizzle/* 8 | !drizzle/.gitkeep 9 | 10 | # dev 11 | .yarn/ 12 | !.yarn/releases 13 | .vscode/* 14 | !.vscode/launch.json 15 | !.vscode/*.code-snippets 16 | .idea/workspace.xml 17 | .idea/usage.statistics.xml 18 | .idea/shelf 19 | 20 | # deps 21 | node_modules/ 22 | .wrangler 23 | 24 | # env 25 | .env 26 | .env.production 27 | .dev.vars 28 | 29 | # logs 30 | logs/ 31 | *.log 32 | npm-debug.log* 33 | yarn-debug.log* 34 | yarn-error.log* 35 | pnpm-debug.log* 36 | lerna-debug.log* 37 | 38 | # misc 39 | .DS_Store 40 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "trailingComma": "es5", 4 | "tabWidth": 2, 5 | "semi": false, 6 | "singleQuote": true, 7 | "jsxSingleQuote": true, 8 | "endOfLine": "lf" 9 | } 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # My first Hono Actions 2 | 3 | ## Setup 4 | 5 | ```bash 6 | npm run wrangler d1 create my-app-actions 7 | npm run migration:generate 8 | npm run migration:apply:local 9 | npm run dev 10 | ``` 11 | 12 | ## Demo 13 | 14 | https://github.com/yusukebe/my-first-hono-actions/assets/10682/5880676a-16e3-4f01-9e2c-1fd764f2b037 15 | -------------------------------------------------------------------------------- /db/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql } from 'drizzle-orm' 2 | import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core' 3 | 4 | export const posts = sqliteTable('posts', { 5 | id: integer('id').primaryKey({ autoIncrement: true }), 6 | title: text('title').notNull(), 7 | body: text('body').notNull(), 8 | createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(strftime('%s', 'now'))`) 9 | }) 10 | 11 | export const likes = sqliteTable('likes', { 12 | id: integer('id').primaryKey({ autoIncrement: true }), 13 | postId: integer('post_id').notNull(), 14 | createdAt: integer('created_at', { mode: 'timestamp' }).default(sql`(strftime('%s', 'now'))`) 15 | }) 16 | -------------------------------------------------------------------------------- /drizzle.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'drizzle-kit' 2 | 3 | export default defineConfig({ 4 | schema: './db/schema.ts', 5 | out: './drizzle', 6 | dialect: 'sqlite' 7 | }) 8 | -------------------------------------------------------------------------------- /drizzle/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yusukebe/my-first-hono-actions/7b7ef20584705e2a42ee479e56acf8693f988aea/drizzle/.gitkeep -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my-app-actions", 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build", 6 | "migration:generate": "drizzle-kit generate", 7 | "migration:apply:local": "wrangler d1 migrations apply my-app-actions --local", 8 | "migration:apply:remote": "wrangler d1 migrations apply my-app-actions --remote", 9 | "preview": "wrangler pages dev", 10 | "deploy": "$npm_execpath run build && wrangler pages deploy" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/workers-types": "^4.20240529.0", 14 | "@hono/vite-cloudflare-pages": "^0.4.1", 15 | "@hono/vite-dev-server": "^0.12.1", 16 | "drizzle-kit": "^0.22.8", 17 | "vite": "^5.2.12", 18 | "wrangler": "^3.57.2" 19 | }, 20 | "type": "module", 21 | "dependencies": { 22 | "drizzle-orm": "^0.31.2", 23 | "zod": "^3.23.8" 24 | } 25 | } -------------------------------------------------------------------------------- /public/static/mvp.css: -------------------------------------------------------------------------------- 1 | /* MVP.css v1.15 - https://github.com/andybrewer/mvp */ 2 | 3 | :root { 4 | --active-brightness: 0.85; 5 | --border-radius: 5px; 6 | --box-shadow: 2px 2px 10px; 7 | --color-accent: #118bee15; 8 | --color-bg: #fff; 9 | --color-bg-secondary: #e9e9e9; 10 | --color-link: #118bee; 11 | --color-secondary: #920de9; 12 | --color-secondary-accent: #920de90b; 13 | --color-shadow: #f4f4f4; 14 | --color-table: #118bee; 15 | --color-text: #000; 16 | --color-text-secondary: #999; 17 | --color-scrollbar: #cacae8; 18 | --font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; 19 | --hover-brightness: 1.2; 20 | --justify-important: center; 21 | --justify-normal: left; 22 | --line-height: 1.5; 23 | --width-card: 285px; 24 | --width-card-medium: 460px; 25 | --width-card-wide: 800px; 26 | --width-content: 1080px; 27 | } 28 | 29 | @media (prefers-color-scheme: dark) { 30 | :root[color-mode="user"] { 31 | --color-accent: #0097fc4f; 32 | --color-bg: #333; 33 | --color-bg-secondary: #555; 34 | --color-link: #0097fc; 35 | --color-secondary: #e20de9; 36 | --color-secondary-accent: #e20de94f; 37 | --color-shadow: #bbbbbb20; 38 | --color-table: #0097fc; 39 | --color-text: #f7f7f7; 40 | --color-text-secondary: #aaa; 41 | } 42 | } 43 | 44 | html { 45 | scroll-behavior: smooth; 46 | } 47 | 48 | @media (prefers-reduced-motion: reduce) { 49 | html { 50 | scroll-behavior: auto; 51 | } 52 | } 53 | 54 | /* Layout */ 55 | article aside { 56 | background: var(--color-secondary-accent); 57 | border-left: 4px solid var(--color-secondary); 58 | padding: 0.01rem 0.8rem; 59 | } 60 | 61 | body { 62 | background: var(--color-bg); 63 | color: var(--color-text); 64 | font-family: var(--font-family); 65 | line-height: var(--line-height); 66 | margin: 0; 67 | overflow-x: hidden; 68 | padding: 0; 69 | } 70 | 71 | footer, 72 | header, 73 | main { 74 | margin: 0 auto; 75 | max-width: var(--width-content); 76 | padding: 3rem 1rem; 77 | } 78 | 79 | hr { 80 | background-color: var(--color-bg-secondary); 81 | border: none; 82 | height: 1px; 83 | margin: 4rem 0; 84 | width: 100%; 85 | } 86 | 87 | section { 88 | display: flex; 89 | flex-wrap: wrap; 90 | justify-content: var(--justify-important); 91 | } 92 | 93 | section img, 94 | article img { 95 | max-width: 100%; 96 | } 97 | 98 | section pre { 99 | overflow: auto; 100 | } 101 | 102 | section aside { 103 | border: 1px solid var(--color-bg-secondary); 104 | border-radius: var(--border-radius); 105 | box-shadow: var(--box-shadow) var(--color-shadow); 106 | margin: 1rem; 107 | padding: 1.25rem; 108 | width: var(--width-card); 109 | } 110 | 111 | section aside:hover { 112 | box-shadow: var(--box-shadow) var(--color-bg-secondary); 113 | } 114 | 115 | [hidden] { 116 | display: none; 117 | } 118 | 119 | /* Headers */ 120 | article header, 121 | div header, 122 | main header { 123 | padding-top: 0; 124 | } 125 | 126 | header { 127 | text-align: var(--justify-important); 128 | } 129 | 130 | header a b, 131 | header a em, 132 | header a i, 133 | header a strong { 134 | margin-left: 0.5rem; 135 | margin-right: 0.5rem; 136 | } 137 | 138 | header nav img { 139 | margin: 1rem 0; 140 | } 141 | 142 | section header { 143 | padding-top: 0; 144 | width: 100%; 145 | } 146 | 147 | /* Nav */ 148 | nav { 149 | align-items: center; 150 | display: flex; 151 | font-weight: bold; 152 | justify-content: space-between; 153 | margin-bottom: 7rem; 154 | } 155 | 156 | nav ul { 157 | list-style: none; 158 | padding: 0; 159 | } 160 | 161 | nav ul li { 162 | display: inline-block; 163 | margin: 0 0.5rem; 164 | position: relative; 165 | text-align: left; 166 | } 167 | 168 | /* Nav Dropdown */ 169 | nav ul li:hover ul { 170 | display: block; 171 | } 172 | 173 | nav ul li ul { 174 | background: var(--color-bg); 175 | border: 1px solid var(--color-bg-secondary); 176 | border-radius: var(--border-radius); 177 | box-shadow: var(--box-shadow) var(--color-shadow); 178 | display: none; 179 | height: auto; 180 | left: -2px; 181 | padding: .5rem 1rem; 182 | position: absolute; 183 | top: 1.7rem; 184 | white-space: nowrap; 185 | width: auto; 186 | z-index: 1; 187 | } 188 | 189 | nav ul li ul::before { 190 | /* fill gap above to make mousing over them easier */ 191 | content: ""; 192 | position: absolute; 193 | left: 0; 194 | right: 0; 195 | top: -0.5rem; 196 | height: 0.5rem; 197 | } 198 | 199 | nav ul li ul li, 200 | nav ul li ul li a { 201 | display: block; 202 | } 203 | 204 | /* Typography */ 205 | code, 206 | samp { 207 | background-color: var(--color-accent); 208 | border-radius: var(--border-radius); 209 | color: var(--color-text); 210 | display: inline-block; 211 | margin: 0 0.1rem; 212 | padding: 0 0.5rem; 213 | } 214 | 215 | details { 216 | margin: 1.3rem 0; 217 | } 218 | 219 | details summary { 220 | font-weight: bold; 221 | cursor: pointer; 222 | } 223 | 224 | h1, 225 | h2, 226 | h3, 227 | h4, 228 | h5, 229 | h6 { 230 | line-height: var(--line-height); 231 | text-wrap: balance; 232 | } 233 | 234 | mark { 235 | padding: 0.1rem; 236 | } 237 | 238 | ol li, 239 | ul li { 240 | padding: 0.2rem 0; 241 | } 242 | 243 | p { 244 | margin: 0.75rem 0; 245 | padding: 0; 246 | width: 100%; 247 | } 248 | 249 | pre { 250 | margin: 1rem 0; 251 | max-width: var(--width-card-wide); 252 | padding: 1rem 0; 253 | } 254 | 255 | pre code, 256 | pre samp { 257 | display: block; 258 | max-width: var(--width-card-wide); 259 | padding: 0.5rem 2rem; 260 | white-space: pre-wrap; 261 | } 262 | 263 | small { 264 | color: var(--color-text-secondary); 265 | } 266 | 267 | sup { 268 | background-color: var(--color-secondary); 269 | border-radius: var(--border-radius); 270 | color: var(--color-bg); 271 | font-size: xx-small; 272 | font-weight: bold; 273 | margin: 0.2rem; 274 | padding: 0.2rem 0.3rem; 275 | position: relative; 276 | top: -2px; 277 | } 278 | 279 | /* Links */ 280 | a { 281 | color: var(--color-link); 282 | display: inline-block; 283 | font-weight: bold; 284 | text-decoration: underline; 285 | } 286 | 287 | a:hover { 288 | filter: brightness(var(--hover-brightness)); 289 | } 290 | 291 | a:active { 292 | filter: brightness(var(--active-brightness)); 293 | } 294 | 295 | a b, 296 | a em, 297 | a i, 298 | a strong, 299 | button, 300 | input[type="submit"] { 301 | border-radius: var(--border-radius); 302 | display: inline-block; 303 | font-size: medium; 304 | font-weight: bold; 305 | line-height: var(--line-height); 306 | margin: 0.5rem 0; 307 | padding: 1rem 2rem; 308 | } 309 | 310 | button, 311 | input[type="submit"] { 312 | font-family: var(--font-family); 313 | } 314 | 315 | button:hover, 316 | input[type="submit"]:hover { 317 | cursor: pointer; 318 | filter: brightness(var(--hover-brightness)); 319 | } 320 | 321 | button:active, 322 | input[type="submit"]:active { 323 | filter: brightness(var(--active-brightness)); 324 | } 325 | 326 | a b, 327 | a strong, 328 | button, 329 | input[type="submit"] { 330 | background-color: var(--color-link); 331 | border: 2px solid var(--color-link); 332 | color: var(--color-bg); 333 | } 334 | 335 | a em, 336 | a i { 337 | border: 2px solid var(--color-link); 338 | border-radius: var(--border-radius); 339 | color: var(--color-link); 340 | display: inline-block; 341 | padding: 1rem 2rem; 342 | } 343 | 344 | article aside a { 345 | color: var(--color-secondary); 346 | } 347 | 348 | /* Images */ 349 | figure { 350 | margin: 0; 351 | padding: 0; 352 | } 353 | 354 | figure img { 355 | max-width: 100%; 356 | } 357 | 358 | figure figcaption { 359 | color: var(--color-text-secondary); 360 | } 361 | 362 | /* Forms */ 363 | button:disabled, 364 | input:disabled { 365 | background: var(--color-bg-secondary); 366 | border-color: var(--color-bg-secondary); 367 | color: var(--color-text-secondary); 368 | cursor: not-allowed; 369 | } 370 | 371 | button[disabled]:hover, 372 | input[type="submit"][disabled]:hover { 373 | filter: none; 374 | } 375 | 376 | form { 377 | border: 1px solid var(--color-bg-secondary); 378 | border-radius: var(--border-radius); 379 | box-shadow: var(--box-shadow) var(--color-shadow); 380 | display: block; 381 | max-width: var(--width-card-wide); 382 | min-width: var(--width-card); 383 | padding: 1.5rem; 384 | text-align: var(--justify-normal); 385 | } 386 | 387 | form header { 388 | margin: 1.5rem 0; 389 | padding: 1.5rem 0; 390 | } 391 | 392 | input, 393 | label, 394 | select, 395 | textarea { 396 | display: block; 397 | font-size: inherit; 398 | max-width: var(--width-card-wide); 399 | } 400 | 401 | input[type="checkbox"], 402 | input[type="radio"] { 403 | display: inline-block; 404 | } 405 | 406 | input[type="checkbox"]+label, 407 | input[type="radio"]+label { 408 | display: inline-block; 409 | font-weight: normal; 410 | position: relative; 411 | top: 1px; 412 | } 413 | 414 | input[type="range"] { 415 | padding: 0.4rem 0; 416 | } 417 | 418 | input, 419 | select, 420 | textarea { 421 | border: 1px solid var(--color-bg-secondary); 422 | border-radius: var(--border-radius); 423 | margin-bottom: 1rem; 424 | padding: 0.4rem 0.8rem; 425 | } 426 | 427 | input[type="text"], 428 | input[type="password"] 429 | textarea { 430 | width: calc(100% - 1.6rem); 431 | } 432 | 433 | input[readonly], 434 | textarea[readonly] { 435 | background-color: var(--color-bg-secondary); 436 | } 437 | 438 | label { 439 | font-weight: bold; 440 | margin-bottom: 0.2rem; 441 | } 442 | 443 | /* Popups */ 444 | dialog { 445 | border: 1px solid var(--color-bg-secondary); 446 | border-radius: var(--border-radius); 447 | box-shadow: var(--box-shadow) var(--color-shadow); 448 | position: fixed; 449 | top: 50%; 450 | left: 50%; 451 | transform: translate(-50%, -50%); 452 | width: 50%; 453 | z-index: 999; 454 | } 455 | 456 | /* Tables */ 457 | table { 458 | border: 1px solid var(--color-bg-secondary); 459 | border-radius: var(--border-radius); 460 | border-spacing: 0; 461 | display: inline-block; 462 | max-width: 100%; 463 | overflow-x: auto; 464 | padding: 0; 465 | white-space: nowrap; 466 | } 467 | 468 | table td, 469 | table th, 470 | table tr { 471 | padding: 0.4rem 0.8rem; 472 | text-align: var(--justify-important); 473 | } 474 | 475 | table thead { 476 | background-color: var(--color-table); 477 | border-collapse: collapse; 478 | border-radius: var(--border-radius); 479 | color: var(--color-bg); 480 | margin: 0; 481 | padding: 0; 482 | } 483 | 484 | table thead tr:first-child th:first-child { 485 | border-top-left-radius: var(--border-radius); 486 | } 487 | 488 | table thead tr:first-child th:last-child { 489 | border-top-right-radius: var(--border-radius); 490 | } 491 | 492 | table thead th:first-child, 493 | table tr td:first-child { 494 | text-align: var(--justify-normal); 495 | } 496 | 497 | table tr:nth-child(even) { 498 | background-color: var(--color-accent); 499 | } 500 | 501 | /* Quotes */ 502 | blockquote { 503 | display: block; 504 | font-size: x-large; 505 | line-height: var(--line-height); 506 | margin: 1rem auto; 507 | max-width: var(--width-card-medium); 508 | padding: 1.5rem 1rem; 509 | text-align: var(--justify-important); 510 | } 511 | 512 | blockquote footer { 513 | color: var(--color-text-secondary); 514 | display: block; 515 | font-size: small; 516 | line-height: var(--line-height); 517 | padding: 1.5rem 0; 518 | } 519 | 520 | /* Scrollbars */ 521 | * { 522 | scrollbar-width: thin; 523 | scrollbar-color: var(--color-scrollbar) transparent; 524 | } 525 | 526 | *::-webkit-scrollbar { 527 | width: 5px; 528 | height: 5px; 529 | } 530 | 531 | *::-webkit-scrollbar-track { 532 | background: transparent; 533 | } 534 | 535 | *::-webkit-scrollbar-thumb { 536 | background-color: var(--color-scrollbar); 537 | border-radius: 10px; 538 | } 539 | -------------------------------------------------------------------------------- /public/static/style.css: -------------------------------------------------------------------------------- 1 | footer, 2 | header, 3 | main { 4 | padding: 1rem; 5 | } 6 | 7 | .error { 8 | color: red; 9 | } 10 | 11 | aside form { 12 | border: none; 13 | display: inline; 14 | box-shadow: none; 15 | padding: 0; 16 | } 17 | 18 | aside form button { 19 | margin-top: 1rem; 20 | padding: 0.2rem; 21 | } 22 | 23 | form[data-hono-disabled='1'] button { 24 | pointer-events: none; 25 | } 26 | -------------------------------------------------------------------------------- /src/create.tsx: -------------------------------------------------------------------------------- 1 | import { createAction } from 'hono/action' 2 | import { factory } from './factory' 3 | import { z } from 'zod' 4 | import { posts } from '../db/schema' 5 | 6 | const app = factory.createApp() 7 | 8 | const schema = z.object({ 9 | title: z.string().min(1, { 10 | message: 'Title is required', 11 | }), 12 | body: z.string().min(1, { 13 | message: 'Body is required', 14 | }), 15 | }) 16 | 17 | const [action, Component] = createAction(app, async (data, c) => { 18 | const result = schema.safeParse(data) 19 | 20 | if (result.success) { 21 | await c.var.db.insert(posts).values({ 22 | title: result.data.title, 23 | body: result.data.body, 24 | }) 25 | return c.redirect('/') 26 | } 27 | 28 | const error = result.error 29 | 30 | return ( 31 | <> 32 | 33 | 41 | {error && ( 42 |
43 | {error!.errors.find((e) => e.path.includes('title'))?.message} 44 |
45 | )} 46 | 47 | 50 | {error && ( 51 |52 | {error!.errors.find((e) => e.path.includes('body'))?.message} 53 |
54 | )} 55 | 56 | > 57 | ) 58 | }) 59 | 60 | app.get('/create', (c) => { 61 | return c.render( 62 |