├── .gitignore
├── 01-tailwind-basic
├── README.md
├── main.py
├── public
│ └── app.css
├── requirements.txt
├── src
│ └── app.css
└── tailwind.config.js
├── 02-card-memory-game
├── Dockerfile
├── README.md
├── assets
│ └── card_imgs
│ │ ├── 1.png
│ │ ├── 10.png
│ │ ├── 11.png
│ │ ├── 12.png
│ │ ├── 2.png
│ │ ├── 3.png
│ │ ├── 4.png
│ │ ├── 5.png
│ │ ├── 6.png
│ │ ├── 7.png
│ │ ├── 8.png
│ │ └── 9.png
├── config.ini
├── fasthtml_hf
│ ├── __init__.py
│ ├── backup.py
│ └── deploy.py
├── main.py
├── public
│ └── app.css
├── requirements.txt
├── src
│ └── app.css
├── svgs.py
└── tailwind.config.js
├── 03-todo-multi-users
├── .ipynb_checkpoints
│ ├── main-checkpoint.ipynb
│ └── main-checkpoint.py
├── main.ipynb
├── main.py
├── public
│ └── app.css
├── requirements.txt
├── src
│ └── app.css
└── tailwind.config.js
├── 04-sqlite-boilerplate
├── .gitignore
├── assets
│ └── logo.svg
├── data
│ └── main.db
├── db.py
├── main.py
├── migrate.py
├── package-lock.json
├── package.json
├── public
│ └── app.css
├── requirements.txt
├── src
│ └── app.css
├── tailwind.config.js
├── templates.py
└── utils.py
├── README.md
└── assets
├── 01-tw-thumb.png
└── 02-card-game.png
/.gitignore:
--------------------------------------------------------------------------------
1 | __pycache__
2 | .sesskey
3 | .env
4 | *.db
5 | *.db-shm
6 | *.db-wal
7 |
--------------------------------------------------------------------------------
/01-tailwind-basic/README.md:
--------------------------------------------------------------------------------
1 | # FastHTML Tailwind App
2 |
3 | This example app demonstrates how to integrate the Tailwind CSS framework into a FastHTML app.
4 |
5 |
6 |
7 | ## Running Locally
8 |
9 | To run the app locally:
10 |
11 | 1. Clone the repository
12 | 2. Navigate to the project directory
13 | 3. Install the standalone TailwindCSS CLI (see below).
14 | 4. Create a new Python environment if you wish.
15 | 5. Install the project dependencies: `pip install -r requirements.txt`
16 | 6. In one terminal start the Python server: `python main.py`
17 | 7. If you wish to edit the Tailwind styles then, in another terminal watch and compile the app CSS file: `tailwindcss -i ./src/app.css -o ./public/app.css --watch`
18 |
19 | ## Installing the TailwindCSS Standalone CLI
20 |
21 | There are three main methods for installing the standalone CLI.
22 |
23 | ### 1. PyPi
24 |
25 | `pip install pytailwindcss`
26 |
27 | I had issues getting this to work on Windows WSL though.
28 |
29 | ### 2. Installing Manually
30 |
31 | You can download the standalone CLI manually with the following commands:
32 |
33 | ```
34 | wget https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.10/tailwindcss-linux-x64
35 | chmod +x tailwindcss-linux-x64
36 | sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
37 | ```
38 |
39 | ### 3. NPM
40 |
41 | Perhaps the easiest method is to just install TailwindCSS via npm, if you don't mind using npm that is. It's only for local development and there is no need to run npm/Node on the server when you deploy your app in production.
42 |
43 | ```
44 | npm install -D tailwindcss
45 | ```
46 |
47 | ### Testing TailwindCSS CLI
48 |
49 | Once intstalled, test that you can run the TailwindCSS standalone CLI via:
50 |
51 | ```
52 | tailwindcss
53 | ```
54 |
55 | You should see the current TailwindCSS CLI version and some usage instructions.
56 |
57 | ### Installing TailwindCSS Plugins
58 |
59 | If you use the standalone TailwindCSS CLI then you can still use any 3rd party Tailwind plugin such as DasiyUI. Simply install the plugin via npm in the usual way and update `tailwind.config.js` accordingly.
60 |
--------------------------------------------------------------------------------
/01-tailwind-basic/main.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 |
3 | app,rt = fast_app(
4 | live=True,
5 | id=int,
6 | title=str,
7 | done=bool,
8 | pk='id',
9 | hdrs=(Link(rel="stylesheet", href="/public/app.css", type="text/css"),),
10 | pico=False,
11 | )
12 |
13 | @rt('/')
14 | def get(): return Title('TailwindCSS in FastHTML'), Div(
15 | Section(
16 | Div(
17 | H1('TailwindCSS in FastHTML!', cls='text-4xl md:text-6xl font-bold mb-4'),
18 | P('And with live reload enabled, this is a great dev experience.', cls='text-lg md:text-2xl mb-8'),
19 | A('Get Started', href='#', cls='bg-white text-blue-500 font-semibold px-6 py-3 rounded-lg shadow-lg hover:bg-gray-100 transition'),
20 | cls='text-center text-white px-6 md:px-12'
21 | ),
22 | cls='min-h-[500px] flex items-center justify-center bg-gradient-to-r from-blue-500 to-purple-600'
23 | ),
24 | Section(
25 | Div(
26 | Div(
27 | H2('Our Features', cls='text-3xl font-bold'),
28 | P('Discover what makes us the best in the business.', cls='text-gray-600'),
29 | cls='text-center mb-12'
30 | ),
31 | Div(
32 | Div(
33 | H3('Feature One', cls='text-xl font-semibold mb-4'),
34 | P('Lorem ipsum dolor sit amet, consectetur adipiscing elit.', cls='text-gray-600'),
35 | cls='bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition'
36 | ),
37 | Div(
38 | H3('Feature Two', cls='text-xl font-semibold mb-4'),
39 | P('Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', cls='text-gray-600'),
40 | cls='bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition'
41 | ),
42 | Div(
43 | H3('Feature Three', cls='text-xl font-semibold mb-4'),
44 | P('Ut enim ad minim veniam, quis nostrud exercitation ullamco.', cls='text-gray-600'),
45 | cls='bg-white p-6 rounded-lg shadow-md hover:shadow-lg transition'
46 | ),
47 | cls='grid grid-cols-1 md:grid-cols-3 gap-8'
48 | ),
49 | cls='max-w-6xl mx-auto px-6'
50 | ),
51 | cls='pt-20 pb-24'
52 | ),
53 | Footer(
54 | P('© 2024 Your Company. All rights reserved.'),
55 | cls='bg-gray-800 text-white text-center py-6'
56 | ),
57 | cls='bg-gray-100 text-gray-800'
58 | )
59 |
60 | serve()
--------------------------------------------------------------------------------
/01-tailwind-basic/public/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 |
3 | /* ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */
4 |
5 | /*
6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
8 | */
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | /* 1 */
15 | border-width: 0;
16 | /* 2 */
17 | border-style: solid;
18 | /* 2 */
19 | border-color: #e5e7eb;
20 | /* 2 */
21 | }
22 |
23 | ::before,
24 | ::after {
25 | --tw-content: '';
26 | }
27 |
28 | /*
29 | 1. Use a consistent sensible line-height in all browsers.
30 | 2. Prevent adjustments of font size after orientation changes in iOS.
31 | 3. Use a more readable tab size.
32 | 4. Use the user's configured `sans` font-family by default.
33 | 5. Use the user's configured `sans` font-feature-settings by default.
34 | */
35 |
36 | html {
37 | line-height: 1.5;
38 | /* 1 */
39 | -webkit-text-size-adjust: 100%;
40 | /* 2 */
41 | -moz-tab-size: 4;
42 | /* 3 */
43 | -o-tab-size: 4;
44 | tab-size: 4;
45 | /* 3 */
46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47 | /* 4 */
48 | font-feature-settings: normal;
49 | /* 5 */
50 | }
51 |
52 | /*
53 | 1. Remove the margin in all browsers.
54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
55 | */
56 |
57 | body {
58 | margin: 0;
59 | /* 1 */
60 | line-height: inherit;
61 | /* 2 */
62 | }
63 |
64 | /*
65 | 1. Add the correct height in Firefox.
66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
67 | 3. Ensure horizontal rules are visible by default.
68 | */
69 |
70 | hr {
71 | height: 0;
72 | /* 1 */
73 | color: inherit;
74 | /* 2 */
75 | border-top-width: 1px;
76 | /* 3 */
77 | }
78 |
79 | /*
80 | Add the correct text decoration in Chrome, Edge, and Safari.
81 | */
82 |
83 | abbr:where([title]) {
84 | -webkit-text-decoration: underline dotted;
85 | text-decoration: underline dotted;
86 | }
87 |
88 | /*
89 | Remove the default font size and weight for headings.
90 | */
91 |
92 | h1,
93 | h2,
94 | h3,
95 | h4,
96 | h5,
97 | h6 {
98 | font-size: inherit;
99 | font-weight: inherit;
100 | }
101 |
102 | /*
103 | Reset links to optimize for opt-in styling instead of opt-out.
104 | */
105 |
106 | a {
107 | color: inherit;
108 | text-decoration: inherit;
109 | }
110 |
111 | /*
112 | Add the correct font weight in Edge and Safari.
113 | */
114 |
115 | b,
116 | strong {
117 | font-weight: bolder;
118 | }
119 |
120 | /*
121 | 1. Use the user's configured `mono` font family by default.
122 | 2. Correct the odd `em` font sizing in all browsers.
123 | */
124 |
125 | code,
126 | kbd,
127 | samp,
128 | pre {
129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
130 | /* 1 */
131 | font-size: 1em;
132 | /* 2 */
133 | }
134 |
135 | /*
136 | Add the correct font size in all browsers.
137 | */
138 |
139 | small {
140 | font-size: 80%;
141 | }
142 |
143 | /*
144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
145 | */
146 |
147 | sub,
148 | sup {
149 | font-size: 75%;
150 | line-height: 0;
151 | position: relative;
152 | vertical-align: baseline;
153 | }
154 |
155 | sub {
156 | bottom: -0.25em;
157 | }
158 |
159 | sup {
160 | top: -0.5em;
161 | }
162 |
163 | /*
164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
166 | 3. Remove gaps between table borders by default.
167 | */
168 |
169 | table {
170 | text-indent: 0;
171 | /* 1 */
172 | border-color: inherit;
173 | /* 2 */
174 | border-collapse: collapse;
175 | /* 3 */
176 | }
177 |
178 | /*
179 | 1. Change the font styles in all browsers.
180 | 2. Remove the margin in Firefox and Safari.
181 | 3. Remove default padding in all browsers.
182 | */
183 |
184 | button,
185 | input,
186 | optgroup,
187 | select,
188 | textarea {
189 | font-family: inherit;
190 | /* 1 */
191 | font-size: 100%;
192 | /* 1 */
193 | font-weight: inherit;
194 | /* 1 */
195 | line-height: inherit;
196 | /* 1 */
197 | color: inherit;
198 | /* 1 */
199 | margin: 0;
200 | /* 2 */
201 | padding: 0;
202 | /* 3 */
203 | }
204 |
205 | /*
206 | Remove the inheritance of text transform in Edge and Firefox.
207 | */
208 |
209 | button,
210 | select {
211 | text-transform: none;
212 | }
213 |
214 | /*
215 | 1. Correct the inability to style clickable types in iOS and Safari.
216 | 2. Remove default button styles.
217 | */
218 |
219 | button,
220 | [type='button'],
221 | [type='reset'],
222 | [type='submit'] {
223 | -webkit-appearance: button;
224 | /* 1 */
225 | background-color: transparent;
226 | /* 2 */
227 | background-image: none;
228 | /* 2 */
229 | }
230 |
231 | /*
232 | Use the modern Firefox focus style for all focusable elements.
233 | */
234 |
235 | :-moz-focusring {
236 | outline: auto;
237 | }
238 |
239 | /*
240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
241 | */
242 |
243 | :-moz-ui-invalid {
244 | box-shadow: none;
245 | }
246 |
247 | /*
248 | Add the correct vertical alignment in Chrome and Firefox.
249 | */
250 |
251 | progress {
252 | vertical-align: baseline;
253 | }
254 |
255 | /*
256 | Correct the cursor style of increment and decrement buttons in Safari.
257 | */
258 |
259 | ::-webkit-inner-spin-button,
260 | ::-webkit-outer-spin-button {
261 | height: auto;
262 | }
263 |
264 | /*
265 | 1. Correct the odd appearance in Chrome and Safari.
266 | 2. Correct the outline style in Safari.
267 | */
268 |
269 | [type='search'] {
270 | -webkit-appearance: textfield;
271 | /* 1 */
272 | outline-offset: -2px;
273 | /* 2 */
274 | }
275 |
276 | /*
277 | Remove the inner padding in Chrome and Safari on macOS.
278 | */
279 |
280 | ::-webkit-search-decoration {
281 | -webkit-appearance: none;
282 | }
283 |
284 | /*
285 | 1. Correct the inability to style clickable types in iOS and Safari.
286 | 2. Change font properties to `inherit` in Safari.
287 | */
288 |
289 | ::-webkit-file-upload-button {
290 | -webkit-appearance: button;
291 | /* 1 */
292 | font: inherit;
293 | /* 2 */
294 | }
295 |
296 | /*
297 | Add the correct display in Chrome and Safari.
298 | */
299 |
300 | summary {
301 | display: list-item;
302 | }
303 |
304 | /*
305 | Removes the default spacing and border for appropriate elements.
306 | */
307 |
308 | blockquote,
309 | dl,
310 | dd,
311 | h1,
312 | h2,
313 | h3,
314 | h4,
315 | h5,
316 | h6,
317 | hr,
318 | figure,
319 | p,
320 | pre {
321 | margin: 0;
322 | }
323 |
324 | fieldset {
325 | margin: 0;
326 | padding: 0;
327 | }
328 |
329 | legend {
330 | padding: 0;
331 | }
332 |
333 | ol,
334 | ul,
335 | menu {
336 | list-style: none;
337 | margin: 0;
338 | padding: 0;
339 | }
340 |
341 | /*
342 | Prevent resizing textareas horizontally by default.
343 | */
344 |
345 | textarea {
346 | resize: vertical;
347 | }
348 |
349 | /*
350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
351 | 2. Set the default placeholder color to the user's configured gray 400 color.
352 | */
353 |
354 | input::-moz-placeholder, textarea::-moz-placeholder {
355 | opacity: 1;
356 | /* 1 */
357 | color: #9ca3af;
358 | /* 2 */
359 | }
360 |
361 | input::placeholder,
362 | textarea::placeholder {
363 | opacity: 1;
364 | /* 1 */
365 | color: #9ca3af;
366 | /* 2 */
367 | }
368 |
369 | /*
370 | Set the default cursor for buttons.
371 | */
372 |
373 | button,
374 | [role="button"] {
375 | cursor: pointer;
376 | }
377 |
378 | /*
379 | Make sure disabled buttons don't get the pointer cursor.
380 | */
381 |
382 | :disabled {
383 | cursor: default;
384 | }
385 |
386 | /*
387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
389 | This can trigger a poorly considered lint error in some tools but is included by design.
390 | */
391 |
392 | img,
393 | svg,
394 | video,
395 | canvas,
396 | audio,
397 | iframe,
398 | embed,
399 | object {
400 | display: block;
401 | /* 1 */
402 | vertical-align: middle;
403 | /* 2 */
404 | }
405 |
406 | /*
407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
408 | */
409 |
410 | img,
411 | video {
412 | max-width: 100%;
413 | height: auto;
414 | }
415 |
416 | /* Make elements with the HTML hidden attribute stay hidden by default */
417 |
418 | [hidden] {
419 | display: none;
420 | }
421 |
422 | *, ::before, ::after {
423 | --tw-border-spacing-x: 0;
424 | --tw-border-spacing-y: 0;
425 | --tw-translate-x: 0;
426 | --tw-translate-y: 0;
427 | --tw-rotate: 0;
428 | --tw-skew-x: 0;
429 | --tw-skew-y: 0;
430 | --tw-scale-x: 1;
431 | --tw-scale-y: 1;
432 | --tw-pan-x: ;
433 | --tw-pan-y: ;
434 | --tw-pinch-zoom: ;
435 | --tw-scroll-snap-strictness: proximity;
436 | --tw-ordinal: ;
437 | --tw-slashed-zero: ;
438 | --tw-numeric-figure: ;
439 | --tw-numeric-spacing: ;
440 | --tw-numeric-fraction: ;
441 | --tw-ring-inset: ;
442 | --tw-ring-offset-width: 0px;
443 | --tw-ring-offset-color: #fff;
444 | --tw-ring-color: rgb(59 130 246 / 0.5);
445 | --tw-ring-offset-shadow: 0 0 #0000;
446 | --tw-ring-shadow: 0 0 #0000;
447 | --tw-shadow: 0 0 #0000;
448 | --tw-shadow-colored: 0 0 #0000;
449 | --tw-blur: ;
450 | --tw-brightness: ;
451 | --tw-contrast: ;
452 | --tw-grayscale: ;
453 | --tw-hue-rotate: ;
454 | --tw-invert: ;
455 | --tw-saturate: ;
456 | --tw-sepia: ;
457 | --tw-drop-shadow: ;
458 | --tw-backdrop-blur: ;
459 | --tw-backdrop-brightness: ;
460 | --tw-backdrop-contrast: ;
461 | --tw-backdrop-grayscale: ;
462 | --tw-backdrop-hue-rotate: ;
463 | --tw-backdrop-invert: ;
464 | --tw-backdrop-opacity: ;
465 | --tw-backdrop-saturate: ;
466 | --tw-backdrop-sepia: ;
467 | }
468 |
469 | ::backdrop {
470 | --tw-border-spacing-x: 0;
471 | --tw-border-spacing-y: 0;
472 | --tw-translate-x: 0;
473 | --tw-translate-y: 0;
474 | --tw-rotate: 0;
475 | --tw-skew-x: 0;
476 | --tw-skew-y: 0;
477 | --tw-scale-x: 1;
478 | --tw-scale-y: 1;
479 | --tw-pan-x: ;
480 | --tw-pan-y: ;
481 | --tw-pinch-zoom: ;
482 | --tw-scroll-snap-strictness: proximity;
483 | --tw-ordinal: ;
484 | --tw-slashed-zero: ;
485 | --tw-numeric-figure: ;
486 | --tw-numeric-spacing: ;
487 | --tw-numeric-fraction: ;
488 | --tw-ring-inset: ;
489 | --tw-ring-offset-width: 0px;
490 | --tw-ring-offset-color: #fff;
491 | --tw-ring-color: rgb(59 130 246 / 0.5);
492 | --tw-ring-offset-shadow: 0 0 #0000;
493 | --tw-ring-shadow: 0 0 #0000;
494 | --tw-shadow: 0 0 #0000;
495 | --tw-shadow-colored: 0 0 #0000;
496 | --tw-blur: ;
497 | --tw-brightness: ;
498 | --tw-contrast: ;
499 | --tw-grayscale: ;
500 | --tw-hue-rotate: ;
501 | --tw-invert: ;
502 | --tw-saturate: ;
503 | --tw-sepia: ;
504 | --tw-drop-shadow: ;
505 | --tw-backdrop-blur: ;
506 | --tw-backdrop-brightness: ;
507 | --tw-backdrop-contrast: ;
508 | --tw-backdrop-grayscale: ;
509 | --tw-backdrop-hue-rotate: ;
510 | --tw-backdrop-invert: ;
511 | --tw-backdrop-opacity: ;
512 | --tw-backdrop-saturate: ;
513 | --tw-backdrop-sepia: ;
514 | }
515 |
516 | .mx-auto {
517 | margin-left: auto;
518 | margin-right: auto;
519 | }
520 |
521 | .mb-4 {
522 | margin-bottom: 1rem;
523 | }
524 |
525 | .mb-8 {
526 | margin-bottom: 2rem;
527 | }
528 |
529 | .mb-12 {
530 | margin-bottom: 3rem;
531 | }
532 |
533 | .flex {
534 | display: flex;
535 | }
536 |
537 | .grid {
538 | display: grid;
539 | }
540 |
541 | .min-h-\[600px\] {
542 | min-height: 600px;
543 | }
544 |
545 | .min-h-\[500px\] {
546 | min-height: 500px;
547 | }
548 |
549 | .max-w-6xl {
550 | max-width: 72rem;
551 | }
552 |
553 | .grid-cols-1 {
554 | grid-template-columns: repeat(1, minmax(0, 1fr));
555 | }
556 |
557 | .items-center {
558 | align-items: center;
559 | }
560 |
561 | .justify-center {
562 | justify-content: center;
563 | }
564 |
565 | .gap-8 {
566 | gap: 2rem;
567 | }
568 |
569 | .rounded-lg {
570 | border-radius: 0.5rem;
571 | }
572 |
573 | .bg-white {
574 | --tw-bg-opacity: 1;
575 | background-color: rgb(255 255 255 / var(--tw-bg-opacity));
576 | }
577 |
578 | .bg-gray-800 {
579 | --tw-bg-opacity: 1;
580 | background-color: rgb(31 41 55 / var(--tw-bg-opacity));
581 | }
582 |
583 | .bg-gray-100 {
584 | --tw-bg-opacity: 1;
585 | background-color: rgb(243 244 246 / var(--tw-bg-opacity));
586 | }
587 |
588 | .bg-gradient-to-r {
589 | background-image: linear-gradient(to right, var(--tw-gradient-stops));
590 | }
591 |
592 | .from-blue-500 {
593 | --tw-gradient-from: #3b82f6;
594 | --tw-gradient-to: rgb(59 130 246 / 0);
595 | --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
596 | }
597 |
598 | .to-purple-600 {
599 | --tw-gradient-to: #9333ea;
600 | }
601 |
602 | .p-6 {
603 | padding: 1.5rem;
604 | }
605 |
606 | .px-6 {
607 | padding-left: 1.5rem;
608 | padding-right: 1.5rem;
609 | }
610 |
611 | .py-3 {
612 | padding-top: 0.75rem;
613 | padding-bottom: 0.75rem;
614 | }
615 |
616 | .py-6 {
617 | padding-top: 1.5rem;
618 | padding-bottom: 1.5rem;
619 | }
620 |
621 | .pt-20 {
622 | padding-top: 5rem;
623 | }
624 |
625 | .pb-24 {
626 | padding-bottom: 6rem;
627 | }
628 |
629 | .text-center {
630 | text-align: center;
631 | }
632 |
633 | .text-4xl {
634 | font-size: 2.25rem;
635 | line-height: 2.5rem;
636 | }
637 |
638 | .text-lg {
639 | font-size: 1.125rem;
640 | line-height: 1.75rem;
641 | }
642 |
643 | .text-3xl {
644 | font-size: 1.875rem;
645 | line-height: 2.25rem;
646 | }
647 |
648 | .text-xl {
649 | font-size: 1.25rem;
650 | line-height: 1.75rem;
651 | }
652 |
653 | .font-bold {
654 | font-weight: 700;
655 | }
656 |
657 | .font-semibold {
658 | font-weight: 600;
659 | }
660 |
661 | .text-blue-500 {
662 | --tw-text-opacity: 1;
663 | color: rgb(59 130 246 / var(--tw-text-opacity));
664 | }
665 |
666 | .text-white {
667 | --tw-text-opacity: 1;
668 | color: rgb(255 255 255 / var(--tw-text-opacity));
669 | }
670 |
671 | .text-gray-600 {
672 | --tw-text-opacity: 1;
673 | color: rgb(75 85 99 / var(--tw-text-opacity));
674 | }
675 |
676 | .text-gray-800 {
677 | --tw-text-opacity: 1;
678 | color: rgb(31 41 55 / var(--tw-text-opacity));
679 | }
680 |
681 | .shadow-lg {
682 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
683 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
684 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
685 | }
686 |
687 | .shadow-md {
688 | --tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
689 | --tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
690 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
691 | }
692 |
693 | .transition {
694 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
695 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
696 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
697 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
698 | transition-duration: 150ms;
699 | }
700 |
701 | .hover\:bg-gray-100:hover {
702 | --tw-bg-opacity: 1;
703 | background-color: rgb(243 244 246 / var(--tw-bg-opacity));
704 | }
705 |
706 | .hover\:shadow-lg:hover {
707 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
708 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
709 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
710 | }
711 |
712 | @media (min-width: 768px) {
713 | .md\:grid-cols-3 {
714 | grid-template-columns: repeat(3, minmax(0, 1fr));
715 | }
716 |
717 | .md\:px-12 {
718 | padding-left: 3rem;
719 | padding-right: 3rem;
720 | }
721 |
722 | .md\:text-6xl {
723 | font-size: 3.75rem;
724 | line-height: 1;
725 | }
726 |
727 | .md\:text-2xl {
728 | font-size: 1.5rem;
729 | line-height: 2rem;
730 | }
731 | }
--------------------------------------------------------------------------------
/01-tailwind-basic/requirements.txt:
--------------------------------------------------------------------------------
1 | python-fasthtml
2 | uvicorn>=0.29
3 | python-multipart
4 | sqlite-utils
--------------------------------------------------------------------------------
/01-tailwind-basic/src/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
--------------------------------------------------------------------------------
/01-tailwind-basic/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["**/*.py"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/02-card-memory-game/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10
2 | WORKDIR /code
3 | COPY --link --chown=1000 . .
4 | RUN mkdir -p /tmp/cache/
5 | RUN chmod a+rwx -R /tmp/cache/
6 | ENV HF_HUB_CACHE=HF_HOME
7 | RUN pip install --no-cache-dir -r requirements.txt
8 |
9 | ENV PYTHONUNBUFFERED=1 PORT=7860
10 | CMD ["python", "main.py"]
11 |
--------------------------------------------------------------------------------
/02-card-memory-game/README.md:
--------------------------------------------------------------------------------
1 | ---
2 | title: FastHTML Card Memory Game
3 | colorFrom: green
4 | colorTo: green
5 | sdk: docker
6 | pinned: false
7 | license: apache-2.0
8 | ---
9 |
10 | # FastHTML Card Matching Memory Game
11 |
12 | This example app demonstrates a simple memory card game using TailwindCSS, AlpineJS inside a FastHTML app.
13 |
14 |
15 |
16 | ## Running Locally
17 |
18 | To run the app locally:
19 |
20 | 1. Clone the repository
21 | 2. Navigate to the project directory
22 | 3. [optional] Install the standalone TailwindCSS CLI (see below).
23 | 4. Create a new Python environment if you wish.
24 | 5. Install the project dependencies: `pip install -r requirements.txt`
25 | 6. In one terminal start the Python server: `python main.py`
26 | 7. If you wish to edit the Tailwind styles then, in another terminal watch and compile the app CSS file: `tailwindcss -i ./src/app.css -o ./public/app.css --watch`
27 |
28 | ## Installing the TailwindCSS Standalone CLI
29 |
30 | There are three main methods for installing the standalone CLI.
31 |
32 | ### 1. PyPi
33 |
34 | `pip install pytailwindcss`
35 |
36 | I had issues getting this to work on Windows WSL though.
37 |
38 | ### 2. Installing Manually
39 |
40 | You can download the standalone CLI manually with the following commands:
41 |
42 | ```
43 | wget https://github.com/tailwindlabs/tailwindcss/releases/download/v3.4.10/tailwindcss-linux-x64
44 | chmod +x tailwindcss-linux-x64
45 | sudo mv tailwindcss-linux-x64 /usr/local/bin/tailwindcss
46 | ```
47 |
48 | ### 3. NPM
49 |
50 | Perhaps the easiest method is to just install TailwindCSS via npm, if you don't mind using npm that is. It's only for local development and there is no need to run npm/Node on the server when you deploy your app in production.
51 |
52 | ```
53 | npm install -D tailwindcss
54 | ```
55 |
56 | ### Testing TailwindCSS CLI
57 |
58 | Once intstalled, test that you can run the TailwindCSS standalone CLI via:
59 |
60 | ```
61 | tailwindcss
62 | ```
63 |
64 | You should see the current TailwindCSS CLI version and some usage instructions.
65 |
66 | ### Installing TailwindCSS Plugins
67 |
68 | If you use the standalone TailwindCSS CLI then you can still use any 3rd party Tailwind plugin such as DasiyUI. Simply install the plugin via npm in the usual way and update `tailwind.config.js` accordingly.
69 |
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/1.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/10.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/10.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/11.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/11.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/12.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/12.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/2.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/3.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/3.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/4.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/4.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/5.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/5.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/6.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/6.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/7.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/7.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/8.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/8.png
--------------------------------------------------------------------------------
/02-card-memory-game/assets/card_imgs/9.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/02-card-memory-game/assets/card_imgs/9.png
--------------------------------------------------------------------------------
/02-card-memory-game/config.ini:
--------------------------------------------------------------------------------
1 | [DEFAULT]
2 | dataset_id = space-backup
3 | db_dir = data
4 | private_backup = True
5 | interval = 15
6 |
7 |
--------------------------------------------------------------------------------
/02-card-memory-game/fasthtml_hf/__init__.py:
--------------------------------------------------------------------------------
1 | from .deploy import *
2 | from .backup import *
3 |
--------------------------------------------------------------------------------
/02-card-memory-game/fasthtml_hf/backup.py:
--------------------------------------------------------------------------------
1 | import os, shutil
2 | os.environ['HF_HUB_DISABLE_PROGRESS_BARS'] = '1'
3 | import time
4 | from fastcore.utils import *
5 | from datetime import datetime
6 | from huggingface_hub import snapshot_download, upload_folder, create_repo, repo_exists, whoami
7 |
8 | __all__ = ['download', 'upload', 'setup_hf_backup']
9 | def _token(): return os.getenv("HF_TOKEN")
10 |
11 | def get_cfg():
12 | return Config('.', 'config.ini',
13 | types=dict(dataset_id=str, db_dir=str, private_backup=bool, interval=int),
14 | create=dict(dataset_id='space-backup', db_dir='data', private_backup=True, interval=15))
15 |
16 | def get_dataset_id(cfg):
17 | did = cfg.dataset_id
18 | if "/" in did or _token() is None: return did
19 | return f"{whoami(_token())['name']}/{did}"
20 |
21 | def download():
22 | cfg = get_cfg()
23 | did = get_dataset_id(cfg)
24 | upload_on_schedule()
25 | if os.getenv("SPACE_ID") and repo_exists(did, repo_type="dataset", token=_token()):
26 | cache_path = snapshot_download(repo_id=did, repo_type='dataset', token=_token())
27 | shutil.copytree(cache_path, cfg.db_dir, dirs_exist_ok=True)
28 |
29 | def upload():
30 | cfg = get_cfg()
31 | if not os.getenv("SPACE_ID"): return
32 | did = get_dataset_id(cfg)
33 | create_repo(did, token=_token(), private=cfg.private_backup, repo_type='dataset', exist_ok=True)
34 | upload_folder(folder_path=cfg.db_dir, token=_token(), repo_id=did,
35 | repo_type='dataset', commit_message=f"backup {datetime.now()}")
36 |
37 |
38 | @threaded
39 | def upload_on_schedule():
40 | cfg = get_cfg()
41 | while True:
42 | time.sleep(cfg.interval*60)
43 | upload()
44 |
45 |
46 | def setup_hf_backup(app):
47 | app.on_event("startup")(download)
48 | app.on_event("shutdown")(upload)
49 |
50 |
--------------------------------------------------------------------------------
/02-card-memory-game/fasthtml_hf/deploy.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from huggingface_hub import create_repo, upload_folder, add_space_secret, whoami
3 | from fastcore.utils import *
4 | from fastcore.script import *
5 |
6 | def _mk_docker(python_ver):
7 | fn = Path('Dockerfile')
8 | if fn.exists(): return
9 | packages = Path('packages.txt')
10 | pkg_line = ''
11 | reqs = Path('requirements.txt')
12 | if not reqs.exists(): reqs.write_text('python-fasthtml\nfasthtml-hf\n')
13 | req_line = f'RUN pip install --no-cache-dir -r requirements.txt'
14 | if packages.exists():
15 | pkglist = ' '.join(packages.readlines())
16 | pkg_line = f'RUN apt-get update -y && apt-get install -y {pkglist}'
17 |
18 | cts = f"""FROM python:{python_ver}
19 | WORKDIR /code
20 | COPY --link --chown=1000 . .
21 | RUN mkdir -p /tmp/cache/
22 | RUN chmod a+rwx -R /tmp/cache/
23 | ENV HF_HUB_CACHE=HF_HOME
24 | {req_line}
25 | {pkg_line}
26 | ENV PYTHONUNBUFFERED=1 PORT=7860
27 | CMD ["python", "main.py"]
28 | """
29 | fn.write_text(cts)
30 |
31 |
32 | def _mk_README(space_id, termination_grace_period):
33 | fn = Path('README.md')
34 | if fn.exists(): return
35 | cts = f"""
36 | ---
37 | title: {space_id}
38 | emoji: 🚀
39 | colorFrom: purple
40 | colorTo: red
41 | sdk: docker
42 | app_file: app.py
43 | pinned: false
44 | termination_grace_period: {termination_grace_period}
45 | ---
46 | """
47 | fn.write_text(cts)
48 |
49 | @call_parse
50 | def deploy(
51 | space_id:str, # ID of the space to upload to
52 | token:str=None, # Hugging Face token for authentication
53 | python_ver:str='3.10', # Version of python to use
54 | upload:bool_arg=True, # Set to `false` to skip uploading files
55 | private:bool_arg=False,
56 | termination_grace_period:str="2m"): # Make the repository private
57 | "Upload current directory to Hugging Face Spaces"
58 | if not token: token=os.getenv('HF_TOKEN')
59 | if not token: return print('No token available')
60 | if "/" not in space_id: space_id = f"{whoami(token)['name']}/{space_id}"
61 | _mk_docker(python_ver)
62 | _mk_README(space_id, termination_grace_period)
63 | private = bool(private) # `private` can be 0,1 or False. As `create_repo` expects private to be True/False we cast it.
64 | url = create_repo(space_id, token=token, repo_type='space',
65 | space_sdk="docker", private=private, exist_ok=True)
66 | if not upload: return print('Repo created; upload skipped')
67 | upload_folder(folder_path=Path("."),
68 | repo_id=space_id, repo_type='space',
69 | ignore_patterns=['__pycache__/*', '.sesskey', 'deploy_hf.py', 'data/*'],
70 | commit_message=f"deploy at {datetime.datetime.now()}",
71 | token=token)
72 | add_space_secret(space_id, token=token, key="HF_TOKEN", value=token)
73 | print(f"Deployed space at {url}")
74 |
75 |
--------------------------------------------------------------------------------
/02-card-memory-game/main.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from fasthtml.svg import *
3 | from svgs import fasthtml_logo
4 | from fasthtml_hf import setup_hf_backup
5 |
6 | app,rt = fast_app(
7 | live=True,
8 | id=int,
9 | title=str,
10 | done=bool,
11 | pk='id',
12 | hdrs=(Script(src="https://unpkg.com/alpinejs", defer=True), Link(rel="stylesheet", href="/public/app.css", type="text/css"),),
13 | pico=False,
14 | debug=True,
15 | )
16 |
17 | js = """
18 | function game() {
19 | const cds = [];
20 | let id = 1;
21 | for (let i = 1; i <= 12; i++) {
22 | cds.push({ id: id++, flipped: false, cleared: false, card: i });
23 | cds.push({ id: id++, flipped: false, cleared: false, card: i });
24 | }
25 | cds.sort((a, b) => 0.5 - Math.random());
26 |
27 | return {
28 | card_clicks: 0,
29 | cheats: 3,
30 | cards: cds,
31 | get flippedCards() {
32 | return this.cards.filter(card => card.flipped);
33 | },
34 | flipCard(card) {
35 | this.card_clicks += 1;
36 | if( card.cleared ) { return; }
37 | if( this.flippedCards.length <= 1 ) { card.flipped = ! card.flipped; }
38 |
39 | if( this.flippedCards.length === 2 ) {
40 | if( this.flippedCards[0].card === this.flippedCards[1].card ) {
41 | this.flippedCards.forEach(card => {
42 | card.cleared = true;
43 | card.flipped = false;
44 | });
45 | } else {
46 | if( card.id !== this.flippedCards[0].id && card.id !== this.flippedCards[1].id ) {
47 | this.flippedCards.forEach(card => {
48 | card.flipped = false;
49 | });
50 | card.flipped = ! card.flipped;
51 | } else {
52 | setTimeout((cd, fl) => {
53 | fl.forEach(card => {
54 | card.flipped = false;
55 | });
56 | }, 1000, card, this.flippedCards);
57 | }
58 | }
59 | }
60 | },
61 | restart() {
62 | this.cards.forEach(c => {
63 | c.flipped = false;
64 | c.cleared = false;
65 | });
66 | this.cheats = 3;
67 | this.card_clicks = 0;
68 | setTimeout(() => {
69 | this.cards.sort((a, b) => 0.5 - Math.random());
70 | }, 500);
71 | },
72 | cheat() {
73 | if( this.cheats >= 1 ) {
74 | this.cheats -= 1;
75 | this.cards.forEach(c => {
76 | c.flipped = true;
77 | });
78 | setTimeout(() => {
79 | this.cards.forEach(c => {
80 | c.flipped = false;
81 | });
82 | }, 750);
83 | }
84 | },
85 | };
86 | }
87 | """
88 |
89 | def cards():
90 | return Template(
91 | Div(
92 | Div(
93 | Div(
94 | NotStr(fasthtml_logo),
95 | cls='flip-card-front'
96 | ),
97 | Div(
98 | Img(
99 | #src='/assets/card_imgs/' + card_num + '.png',
100 | **{":src": "'/assets/card_imgs/' + card.card + '.png'"},
101 | ),
102 | cls='flip-card-back'
103 | ),
104 | cls='flip-card-inner',
105 | **{":class": "{'flip-rotate-y-180': card.flipped || card.cleared}"},
106 | ),
107 | tabindex='0',
108 | cls='flip-card',
109 | **{"@click": "flipCard(card)"},
110 | **{":class": "{'flip-cleared': card.cleared}"},
111 | ),
112 | x_for='card in cards',
113 | id="fr",
114 | data_something="123"
115 | )
116 |
117 | @rt('/')
118 | def get(): return Title('Card Memory Game in FastHTML'), Div(
119 | Section(
120 | Div(
121 | H1('Card Memory Game', cls='text-4xl md:text-6xl font-bold mb-4'),
122 | P('Built with FastHTML, HTMX, TailwindCSS, and AlpineJS.', cls='text-lg md:text-2xl'),
123 | cls='text-center text-white px-6 md:px-12 drop-shadow-lg'
124 | ),
125 | cls='min-h-[300px] flex items-center justify-center bg-gradient-to-r from-[#3cdd8c] to-[#ffdb6d]'
126 | ),
127 | Section(
128 | Div(
129 | Div(
130 | Div(
131 | cards(),
132 | cls='grid grid-cols-6 gap-4 w-[944px]'
133 | ),
134 | ),
135 | cls='flex items-center justify-center mt-10'
136 | ),
137 | Div(
138 | Div(
139 | Div(),
140 | cls='flex items-center justify-center my-5 font-bold',
141 | **{"x-text": "'Card clicks: ' + card_clicks"},
142 | ),
143 | Div(
144 | A(
145 | 'Restart Game',
146 | cls='bg-white text-[#3cdd8c] font-semibold px-6 py-3 rounded-lg shadow-lg hover:bg-gray-100 transition',
147 | **{"x-on:click.prevent": "restart()"},
148 | ),
149 | A(
150 | cls='bg-white text-[#3cdd8c] font-semibold px-6 py-3 rounded-lg shadow-lg hover:bg-gray-100 transition',
151 | **{"x-on:click.prevent": "cheat()"},
152 | **{"x-text": "'Cheat Mode! (' + cheats + ')'"},
153 | ),
154 | cls='flex items-center justify-center gap-4'
155 | ),
156 | Div(
157 | 'Reveal all the matching pairs of cards in the fewest clicks possible.',
158 | cls='mt-6 flex items-center justify-center gap-4'
159 | ),
160 | Div(
161 | 'Click the \'Cheat Mode\' button to reveal all tiles, but you only have three per game so use wisely!',
162 | cls='flex items-center justify-center gap-4'
163 | ),
164 | ),
165 | cls='mb-20',
166 | x_data='game()',
167 | ),
168 | Footer(
169 | A('Created by David Gwyer - Follow me on X', href="https://x.com/dgwyer", _target="_blank", cls="drop-shadow-lg text-lg"),
170 | cls='bg-gray-800 text-white text-center py-8'
171 | ),
172 | cls='bg-gray-100 text-gray-800'
173 | ), Script(js)
174 |
175 | setup_hf_backup(app)
176 |
177 | serve(reload_includes=["*.css"])
178 |
--------------------------------------------------------------------------------
/02-card-memory-game/public/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 |
3 | /* ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */
4 |
5 | /*
6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
8 | */
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | /* 1 */
15 | border-width: 0;
16 | /* 2 */
17 | border-style: solid;
18 | /* 2 */
19 | border-color: #e5e7eb;
20 | /* 2 */
21 | }
22 |
23 | ::before,
24 | ::after {
25 | --tw-content: '';
26 | }
27 |
28 | /*
29 | 1. Use a consistent sensible line-height in all browsers.
30 | 2. Prevent adjustments of font size after orientation changes in iOS.
31 | 3. Use a more readable tab size.
32 | 4. Use the user's configured `sans` font-family by default.
33 | 5. Use the user's configured `sans` font-feature-settings by default.
34 | */
35 |
36 | html {
37 | line-height: 1.5;
38 | /* 1 */
39 | -webkit-text-size-adjust: 100%;
40 | /* 2 */
41 | -moz-tab-size: 4;
42 | /* 3 */
43 | -o-tab-size: 4;
44 | tab-size: 4;
45 | /* 3 */
46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47 | /* 4 */
48 | font-feature-settings: normal;
49 | /* 5 */
50 | }
51 |
52 | /*
53 | 1. Remove the margin in all browsers.
54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
55 | */
56 |
57 | body {
58 | margin: 0;
59 | /* 1 */
60 | line-height: inherit;
61 | /* 2 */
62 | }
63 |
64 | /*
65 | 1. Add the correct height in Firefox.
66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
67 | 3. Ensure horizontal rules are visible by default.
68 | */
69 |
70 | hr {
71 | height: 0;
72 | /* 1 */
73 | color: inherit;
74 | /* 2 */
75 | border-top-width: 1px;
76 | /* 3 */
77 | }
78 |
79 | /*
80 | Add the correct text decoration in Chrome, Edge, and Safari.
81 | */
82 |
83 | abbr:where([title]) {
84 | -webkit-text-decoration: underline dotted;
85 | text-decoration: underline dotted;
86 | }
87 |
88 | /*
89 | Remove the default font size and weight for headings.
90 | */
91 |
92 | h1,
93 | h2,
94 | h3,
95 | h4,
96 | h5,
97 | h6 {
98 | font-size: inherit;
99 | font-weight: inherit;
100 | }
101 |
102 | /*
103 | Reset links to optimize for opt-in styling instead of opt-out.
104 | */
105 |
106 | a {
107 | color: inherit;
108 | text-decoration: inherit;
109 | }
110 |
111 | /*
112 | Add the correct font weight in Edge and Safari.
113 | */
114 |
115 | b,
116 | strong {
117 | font-weight: bolder;
118 | }
119 |
120 | /*
121 | 1. Use the user's configured `mono` font family by default.
122 | 2. Correct the odd `em` font sizing in all browsers.
123 | */
124 |
125 | code,
126 | kbd,
127 | samp,
128 | pre {
129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
130 | /* 1 */
131 | font-size: 1em;
132 | /* 2 */
133 | }
134 |
135 | /*
136 | Add the correct font size in all browsers.
137 | */
138 |
139 | small {
140 | font-size: 80%;
141 | }
142 |
143 | /*
144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
145 | */
146 |
147 | sub,
148 | sup {
149 | font-size: 75%;
150 | line-height: 0;
151 | position: relative;
152 | vertical-align: baseline;
153 | }
154 |
155 | sub {
156 | bottom: -0.25em;
157 | }
158 |
159 | sup {
160 | top: -0.5em;
161 | }
162 |
163 | /*
164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
166 | 3. Remove gaps between table borders by default.
167 | */
168 |
169 | table {
170 | text-indent: 0;
171 | /* 1 */
172 | border-color: inherit;
173 | /* 2 */
174 | border-collapse: collapse;
175 | /* 3 */
176 | }
177 |
178 | /*
179 | 1. Change the font styles in all browsers.
180 | 2. Remove the margin in Firefox and Safari.
181 | 3. Remove default padding in all browsers.
182 | */
183 |
184 | button,
185 | input,
186 | optgroup,
187 | select,
188 | textarea {
189 | font-family: inherit;
190 | /* 1 */
191 | font-size: 100%;
192 | /* 1 */
193 | font-weight: inherit;
194 | /* 1 */
195 | line-height: inherit;
196 | /* 1 */
197 | color: inherit;
198 | /* 1 */
199 | margin: 0;
200 | /* 2 */
201 | padding: 0;
202 | /* 3 */
203 | }
204 |
205 | /*
206 | Remove the inheritance of text transform in Edge and Firefox.
207 | */
208 |
209 | button,
210 | select {
211 | text-transform: none;
212 | }
213 |
214 | /*
215 | 1. Correct the inability to style clickable types in iOS and Safari.
216 | 2. Remove default button styles.
217 | */
218 |
219 | button,
220 | [type='button'],
221 | [type='reset'],
222 | [type='submit'] {
223 | -webkit-appearance: button;
224 | /* 1 */
225 | background-color: transparent;
226 | /* 2 */
227 | background-image: none;
228 | /* 2 */
229 | }
230 |
231 | /*
232 | Use the modern Firefox focus style for all focusable elements.
233 | */
234 |
235 | :-moz-focusring {
236 | outline: auto;
237 | }
238 |
239 | /*
240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
241 | */
242 |
243 | :-moz-ui-invalid {
244 | box-shadow: none;
245 | }
246 |
247 | /*
248 | Add the correct vertical alignment in Chrome and Firefox.
249 | */
250 |
251 | progress {
252 | vertical-align: baseline;
253 | }
254 |
255 | /*
256 | Correct the cursor style of increment and decrement buttons in Safari.
257 | */
258 |
259 | ::-webkit-inner-spin-button,
260 | ::-webkit-outer-spin-button {
261 | height: auto;
262 | }
263 |
264 | /*
265 | 1. Correct the odd appearance in Chrome and Safari.
266 | 2. Correct the outline style in Safari.
267 | */
268 |
269 | [type='search'] {
270 | -webkit-appearance: textfield;
271 | /* 1 */
272 | outline-offset: -2px;
273 | /* 2 */
274 | }
275 |
276 | /*
277 | Remove the inner padding in Chrome and Safari on macOS.
278 | */
279 |
280 | ::-webkit-search-decoration {
281 | -webkit-appearance: none;
282 | }
283 |
284 | /*
285 | 1. Correct the inability to style clickable types in iOS and Safari.
286 | 2. Change font properties to `inherit` in Safari.
287 | */
288 |
289 | ::-webkit-file-upload-button {
290 | -webkit-appearance: button;
291 | /* 1 */
292 | font: inherit;
293 | /* 2 */
294 | }
295 |
296 | /*
297 | Add the correct display in Chrome and Safari.
298 | */
299 |
300 | summary {
301 | display: list-item;
302 | }
303 |
304 | /*
305 | Removes the default spacing and border for appropriate elements.
306 | */
307 |
308 | blockquote,
309 | dl,
310 | dd,
311 | h1,
312 | h2,
313 | h3,
314 | h4,
315 | h5,
316 | h6,
317 | hr,
318 | figure,
319 | p,
320 | pre {
321 | margin: 0;
322 | }
323 |
324 | fieldset {
325 | margin: 0;
326 | padding: 0;
327 | }
328 |
329 | legend {
330 | padding: 0;
331 | }
332 |
333 | ol,
334 | ul,
335 | menu {
336 | list-style: none;
337 | margin: 0;
338 | padding: 0;
339 | }
340 |
341 | /*
342 | Prevent resizing textareas horizontally by default.
343 | */
344 |
345 | textarea {
346 | resize: vertical;
347 | }
348 |
349 | /*
350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
351 | 2. Set the default placeholder color to the user's configured gray 400 color.
352 | */
353 |
354 | input::-moz-placeholder, textarea::-moz-placeholder {
355 | opacity: 1;
356 | /* 1 */
357 | color: #9ca3af;
358 | /* 2 */
359 | }
360 |
361 | input::placeholder,
362 | textarea::placeholder {
363 | opacity: 1;
364 | /* 1 */
365 | color: #9ca3af;
366 | /* 2 */
367 | }
368 |
369 | /*
370 | Set the default cursor for buttons.
371 | */
372 |
373 | button,
374 | [role="button"] {
375 | cursor: pointer;
376 | }
377 |
378 | /*
379 | Make sure disabled buttons don't get the pointer cursor.
380 | */
381 |
382 | :disabled {
383 | cursor: default;
384 | }
385 |
386 | /*
387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
389 | This can trigger a poorly considered lint error in some tools but is included by design.
390 | */
391 |
392 | img,
393 | svg,
394 | video,
395 | canvas,
396 | audio,
397 | iframe,
398 | embed,
399 | object {
400 | display: block;
401 | /* 1 */
402 | vertical-align: middle;
403 | /* 2 */
404 | }
405 |
406 | /*
407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
408 | */
409 |
410 | img,
411 | video {
412 | max-width: 100%;
413 | height: auto;
414 | }
415 |
416 | /* Make elements with the HTML hidden attribute stay hidden by default */
417 |
418 | [hidden] {
419 | display: none;
420 | }
421 |
422 | *, ::before, ::after {
423 | --tw-border-spacing-x: 0;
424 | --tw-border-spacing-y: 0;
425 | --tw-translate-x: 0;
426 | --tw-translate-y: 0;
427 | --tw-rotate: 0;
428 | --tw-skew-x: 0;
429 | --tw-skew-y: 0;
430 | --tw-scale-x: 1;
431 | --tw-scale-y: 1;
432 | --tw-pan-x: ;
433 | --tw-pan-y: ;
434 | --tw-pinch-zoom: ;
435 | --tw-scroll-snap-strictness: proximity;
436 | --tw-ordinal: ;
437 | --tw-slashed-zero: ;
438 | --tw-numeric-figure: ;
439 | --tw-numeric-spacing: ;
440 | --tw-numeric-fraction: ;
441 | --tw-ring-inset: ;
442 | --tw-ring-offset-width: 0px;
443 | --tw-ring-offset-color: #fff;
444 | --tw-ring-color: rgb(59 130 246 / 0.5);
445 | --tw-ring-offset-shadow: 0 0 #0000;
446 | --tw-ring-shadow: 0 0 #0000;
447 | --tw-shadow: 0 0 #0000;
448 | --tw-shadow-colored: 0 0 #0000;
449 | --tw-blur: ;
450 | --tw-brightness: ;
451 | --tw-contrast: ;
452 | --tw-grayscale: ;
453 | --tw-hue-rotate: ;
454 | --tw-invert: ;
455 | --tw-saturate: ;
456 | --tw-sepia: ;
457 | --tw-drop-shadow: ;
458 | --tw-backdrop-blur: ;
459 | --tw-backdrop-brightness: ;
460 | --tw-backdrop-contrast: ;
461 | --tw-backdrop-grayscale: ;
462 | --tw-backdrop-hue-rotate: ;
463 | --tw-backdrop-invert: ;
464 | --tw-backdrop-opacity: ;
465 | --tw-backdrop-saturate: ;
466 | --tw-backdrop-sepia: ;
467 | }
468 |
469 | ::backdrop {
470 | --tw-border-spacing-x: 0;
471 | --tw-border-spacing-y: 0;
472 | --tw-translate-x: 0;
473 | --tw-translate-y: 0;
474 | --tw-rotate: 0;
475 | --tw-skew-x: 0;
476 | --tw-skew-y: 0;
477 | --tw-scale-x: 1;
478 | --tw-scale-y: 1;
479 | --tw-pan-x: ;
480 | --tw-pan-y: ;
481 | --tw-pinch-zoom: ;
482 | --tw-scroll-snap-strictness: proximity;
483 | --tw-ordinal: ;
484 | --tw-slashed-zero: ;
485 | --tw-numeric-figure: ;
486 | --tw-numeric-spacing: ;
487 | --tw-numeric-fraction: ;
488 | --tw-ring-inset: ;
489 | --tw-ring-offset-width: 0px;
490 | --tw-ring-offset-color: #fff;
491 | --tw-ring-color: rgb(59 130 246 / 0.5);
492 | --tw-ring-offset-shadow: 0 0 #0000;
493 | --tw-ring-shadow: 0 0 #0000;
494 | --tw-shadow: 0 0 #0000;
495 | --tw-shadow-colored: 0 0 #0000;
496 | --tw-blur: ;
497 | --tw-brightness: ;
498 | --tw-contrast: ;
499 | --tw-grayscale: ;
500 | --tw-hue-rotate: ;
501 | --tw-invert: ;
502 | --tw-saturate: ;
503 | --tw-sepia: ;
504 | --tw-drop-shadow: ;
505 | --tw-backdrop-blur: ;
506 | --tw-backdrop-brightness: ;
507 | --tw-backdrop-contrast: ;
508 | --tw-backdrop-grayscale: ;
509 | --tw-backdrop-hue-rotate: ;
510 | --tw-backdrop-invert: ;
511 | --tw-backdrop-opacity: ;
512 | --tw-backdrop-saturate: ;
513 | --tw-backdrop-sepia: ;
514 | }
515 |
516 | .absolute {
517 | position: absolute;
518 | }
519 |
520 | .relative {
521 | position: relative;
522 | }
523 |
524 | .my-20 {
525 | margin-top: 5rem;
526 | margin-bottom: 5rem;
527 | }
528 |
529 | .my-10 {
530 | margin-top: 2.5rem;
531 | margin-bottom: 2.5rem;
532 | }
533 |
534 | .my-5 {
535 | margin-top: 1.25rem;
536 | margin-bottom: 1.25rem;
537 | }
538 |
539 | .mb-4 {
540 | margin-bottom: 1rem;
541 | }
542 |
543 | .mt-6 {
544 | margin-top: 1.5rem;
545 | }
546 |
547 | .mb-20 {
548 | margin-bottom: 5rem;
549 | }
550 |
551 | .mt-10 {
552 | margin-top: 2.5rem;
553 | }
554 |
555 | .flex {
556 | display: flex;
557 | }
558 |
559 | .grid {
560 | display: grid;
561 | }
562 |
563 | .hidden {
564 | display: none;
565 | }
566 |
567 | .min-h-\[300px\] {
568 | min-height: 300px;
569 | }
570 |
571 | .w-\[944px\] {
572 | width: 944px;
573 | }
574 |
575 | .w-96 {
576 | width: 24rem;
577 | }
578 |
579 | .w-\[500px\] {
580 | width: 500px;
581 | }
582 |
583 | .w-\[400px\] {
584 | width: 400px;
585 | }
586 |
587 | .transform {
588 | transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));
589 | }
590 |
591 | .grid-cols-6 {
592 | grid-template-columns: repeat(6, minmax(0, 1fr));
593 | }
594 |
595 | .items-center {
596 | align-items: center;
597 | }
598 |
599 | .justify-center {
600 | justify-content: center;
601 | }
602 |
603 | .gap-4 {
604 | gap: 1rem;
605 | }
606 |
607 | .rounded-lg {
608 | border-radius: 0.5rem;
609 | }
610 |
611 | .bg-white {
612 | --tw-bg-opacity: 1;
613 | background-color: rgb(255 255 255 / var(--tw-bg-opacity));
614 | }
615 |
616 | .bg-gray-800 {
617 | --tw-bg-opacity: 1;
618 | background-color: rgb(31 41 55 / var(--tw-bg-opacity));
619 | }
620 |
621 | .bg-gray-100 {
622 | --tw-bg-opacity: 1;
623 | background-color: rgb(243 244 246 / var(--tw-bg-opacity));
624 | }
625 |
626 | .bg-gradient-to-r {
627 | background-image: linear-gradient(to right, var(--tw-gradient-stops));
628 | }
629 |
630 | .from-\[\#3cdd8c\] {
631 | --tw-gradient-from: #3cdd8c;
632 | --tw-gradient-to: rgb(60 221 140 / 0);
633 | --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to);
634 | }
635 |
636 | .to-\[\#ffdb6d\] {
637 | --tw-gradient-to: #ffdb6d;
638 | }
639 |
640 | .px-6 {
641 | padding-left: 1.5rem;
642 | padding-right: 1.5rem;
643 | }
644 |
645 | .py-3 {
646 | padding-top: 0.75rem;
647 | padding-bottom: 0.75rem;
648 | }
649 |
650 | .py-8 {
651 | padding-top: 2rem;
652 | padding-bottom: 2rem;
653 | }
654 |
655 | .text-center {
656 | text-align: center;
657 | }
658 |
659 | .text-4xl {
660 | font-size: 2.25rem;
661 | line-height: 2.5rem;
662 | }
663 |
664 | .text-lg {
665 | font-size: 1.125rem;
666 | line-height: 1.75rem;
667 | }
668 |
669 | .font-bold {
670 | font-weight: 700;
671 | }
672 |
673 | .font-semibold {
674 | font-weight: 600;
675 | }
676 |
677 | .text-white {
678 | --tw-text-opacity: 1;
679 | color: rgb(255 255 255 / var(--tw-text-opacity));
680 | }
681 |
682 | .text-\[\#3cdd8c\] {
683 | --tw-text-opacity: 1;
684 | color: rgb(60 221 140 / var(--tw-text-opacity));
685 | }
686 |
687 | .text-gray-800 {
688 | --tw-text-opacity: 1;
689 | color: rgb(31 41 55 / var(--tw-text-opacity));
690 | }
691 |
692 | .shadow-lg {
693 | --tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
694 | --tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
695 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
696 | }
697 |
698 | .outline {
699 | outline-style: solid;
700 | }
701 |
702 | .drop-shadow-lg {
703 | --tw-drop-shadow: drop-shadow(0 10px 8px rgb(0 0 0 / 0.04)) drop-shadow(0 4px 3px rgb(0 0 0 / 0.1));
704 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
705 | }
706 |
707 | .filter {
708 | filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
709 | }
710 |
711 | .transition {
712 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
713 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
714 | transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
715 | transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
716 | transition-duration: 150ms;
717 | }
718 |
719 | /* Card flip styles based on this CodePen example: https: //codepen.io/ananyaneogi/pen/Ezmyeb */
720 |
721 | .flip-card {
722 | background-color: transparent;
723 | width: 144px;
724 | height: 144px;
725 | perspective: 1000px;
726 | cursor: pointer;
727 | }
728 |
729 | .flip-card-inner {
730 | position: relative;
731 | width: 100%;
732 | height: 100%;
733 | text-align: center;
734 | transition: transform 0.4s;
735 | transform-style: preserve-3d;
736 | -webkit-backface-visibility: hidden;
737 | backface-visibility: hidden;
738 | -moz-backface-visibility: hidden;
739 | }
740 |
741 | .flip-card:focus {
742 | outline: 0;
743 | }
744 |
745 | /*.flip-card:hover .flip-card-inner {
746 | transform: rotateY(180deg);
747 | }*/
748 |
749 | .flip-rotate-y-180 {
750 | transform: rotateY(180deg);
751 | }
752 |
753 | .flip-cleared {
754 | opacity: 0.6;
755 | }
756 |
757 | .flip-card-front,
758 | .flip-card-back {
759 | position: absolute;
760 | width: 100%;
761 | height: 100%;
762 | }
763 |
764 | .flip-card-front,
765 | .flip-card-back,
766 | .flip-card-back img {
767 | border-radius: 20px;
768 | }
769 |
770 | .flip-card-front {
771 | background: #3cdd8c;
772 | color: white;
773 | z-index: 2;
774 | display: flex;
775 | justify-content: center;
776 | align-items: center;
777 | }
778 |
779 | .flip-card-back {
780 | background: linear-gradient(to right, #4364f7, #6fb1fc);
781 | color: white;
782 | transform: rotateY(180deg);
783 | z-index: 1;
784 | display: flex;
785 | justify-content: center;
786 | align-items: center;
787 | }
788 |
789 | .hover\:bg-gray-100:hover {
790 | --tw-bg-opacity: 1;
791 | background-color: rgb(243 244 246 / var(--tw-bg-opacity));
792 | }
793 |
794 | @media (min-width: 768px) {
795 | .md\:px-12 {
796 | padding-left: 3rem;
797 | padding-right: 3rem;
798 | }
799 |
800 | .md\:text-6xl {
801 | font-size: 3.75rem;
802 | line-height: 1;
803 | }
804 |
805 | .md\:text-2xl {
806 | font-size: 1.5rem;
807 | line-height: 2rem;
808 | }
809 | }
--------------------------------------------------------------------------------
/02-card-memory-game/requirements.txt:
--------------------------------------------------------------------------------
1 | python-fasthtml
2 | uvicorn>=0.29
3 | python-multipart
4 | sqlite-utils
5 | huggingface-hub>=0.20.0
6 | fasthtml-hf
--------------------------------------------------------------------------------
/02-card-memory-game/src/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | /* Card flip styles based on this CodePen example: https: //codepen.io/ananyaneogi/pen/Ezmyeb */
7 | .flip-card {
8 | background-color: transparent;
9 | width: 144px;
10 | height: 144px;
11 | perspective: 1000px;
12 | cursor: pointer;
13 | }
14 |
15 | .flip-card-inner {
16 | position: relative;
17 | width: 100%;
18 | height: 100%;
19 | text-align: center;
20 | transition: transform 0.4s;
21 | transform-style: preserve-3d;
22 | backface-visibility: hidden;
23 | -moz-backface-visibility: hidden;
24 | }
25 |
26 | .flip-card:focus {
27 | outline: 0;
28 | }
29 |
30 | /*.flip-card:hover .flip-card-inner {
31 | transform: rotateY(180deg);
32 | }*/
33 |
34 | .flip-rotate-y-180 {
35 | transform: rotateY(180deg);
36 | }
37 |
38 | .flip-cleared {
39 | opacity: 0.6;
40 | }
41 |
42 | .flip-card-front,
43 | .flip-card-back {
44 | position: absolute;
45 | width: 100%;
46 | height: 100%;
47 | }
48 |
49 | .flip-card-front,
50 | .flip-card-back,
51 | .flip-card-back img {
52 | border-radius: 20px;
53 | }
54 |
55 | .flip-card-front {
56 | background: #3cdd8c;
57 | color: white;
58 | z-index: 2;
59 | display: flex;
60 | justify-content: center;
61 | align-items: center;
62 | }
63 |
64 | .flip-card-back {
65 | background: linear-gradient(to right, #4364f7, #6fb1fc);
66 | color: white;
67 | transform: rotateY(180deg);
68 | z-index: 1;
69 | display: flex;
70 | justify-content: center;
71 | align-items: center;
72 | }
--------------------------------------------------------------------------------
/02-card-memory-game/svgs.py:
--------------------------------------------------------------------------------
1 | fasthtml_logo = ''
2 |
--------------------------------------------------------------------------------
/02-card-memory-game/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["**/*.py", "src/*.css"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/03-todo-multi-users/.ipynb_checkpoints/main-checkpoint.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 10,
6 | "id": "23899eee-df2b-49b6-beec-062244b0394c",
7 | "metadata": {},
8 | "outputs": [],
9 | "source": [
10 | "from fastlite import *\n",
11 | "from fastcore.utils import *\n",
12 | "from fastcore.net import urlsave"
13 | ]
14 | },
15 | {
16 | "cell_type": "code",
17 | "execution_count": 11,
18 | "id": "7ab9e946-268f-4010-ae8d-60ca6de4b94e",
19 | "metadata": {},
20 | "outputs": [],
21 | "source": [
22 | "db = database(\"data/utodos.db\")"
23 | ]
24 | },
25 | {
26 | "cell_type": "code",
27 | "execution_count": 13,
28 | "id": "d9d0338e-d5f9-43d0-8ffb-46883591c15d",
29 | "metadata": {},
30 | "outputs": [
31 | {
32 | "data": {
33 | "image/svg+xml": [
34 | "\n",
35 | "\n",
37 | "\n",
39 | "\n",
40 | "\n"
83 | ],
84 | "text/plain": [
85 | ""
86 | ]
87 | },
88 | "execution_count": 13,
89 | "metadata": {},
90 | "output_type": "execute_result"
91 | }
92 | ],
93 | "source": [
94 | "diagram(db.tables)"
95 | ]
96 | },
97 | {
98 | "cell_type": "code",
99 | "execution_count": null,
100 | "id": "2d1e10c8-3c36-4b3d-8135-f76a849dd00f",
101 | "metadata": {},
102 | "outputs": [],
103 | "source": []
104 | }
105 | ],
106 | "metadata": {
107 | "kernelspec": {
108 | "display_name": "Python 3 (ipykernel)",
109 | "language": "python",
110 | "name": "python3"
111 | },
112 | "language_info": {
113 | "codemirror_mode": {
114 | "name": "ipython",
115 | "version": 3
116 | },
117 | "file_extension": ".py",
118 | "mimetype": "text/x-python",
119 | "name": "python",
120 | "nbconvert_exporter": "python",
121 | "pygments_lexer": "ipython3",
122 | "version": "3.12.5"
123 | }
124 | },
125 | "nbformat": 4,
126 | "nbformat_minor": 5
127 | }
128 |
--------------------------------------------------------------------------------
/03-todo-multi-users/.ipynb_checkpoints/main-checkpoint.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from hmac import compare_digest
3 | import subprocess
4 |
5 | db = database('data/utodos.db')
6 |
7 | todos,users = db.t.todos,db.t.users
8 | if todos not in db.t:
9 | users.create(dict(id=int, name=str, pwd=str), pk='id')
10 | todos.create(id=int, title=str, done=bool, name=str, details=str, priority=int, pk='id')
11 | Todo,User = todos.dataclass(),users.dataclass()
12 |
13 | # Status code 303 is a redirect that can change POST to GET, so it's appropriate for a login page.
14 | login_redir = RedirectResponse('/login', status_code=303)
15 |
16 | # The `before` function is a *Beforeware* function. These are functions that run before a route handler is called.
17 | def before(req, sess):
18 | auth = req.scope['auth'] = sess.get('auth', None)
19 | if not auth: return login_redir
20 | todos.xtra(name=auth)
21 |
22 | markdown_js = """
23 | import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
24 | proc_htmx('.markdown', e => e.innerHTML = marked.parse(e.textContent));
25 | """
26 |
27 | # We will use this in our `exception_handlers` dict
28 | def _not_found(req, exc): return Titled('Oh no!', Div('We could not find that page :('))
29 |
30 | # To create a Beforeware object, we pass the function itself, and optionally a list of regexes to skip.
31 | bware = Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', '/login'])
32 | app = FastHTMLWithLiveReload(before=bware,
33 | exception_handlers={404: _not_found},
34 | hdrs=(picolink,
35 | Style(':root { --pico-font-size: 100%; }'),
36 | SortableJS('.sortable'),
37 | Script(markdown_js, type='module'))
38 | )
39 | rt = app.route
40 |
41 | @rt("/login")
42 | def get():
43 | frm = Form(
44 | Input(id='name', placeholder='Name'),
45 | Input(id='pwd', type='password', placeholder='Password'),
46 | Button('login'),
47 | action='/login', method='post')
48 | return Titled("Login", frm)
49 |
50 | @dataclass
51 | class Login: name:str; pwd:str
52 |
53 | @rt("/login")
54 | def post(login:Login, sess):
55 | if not login.name or not login.pwd: return login_redir
56 | try: u = users[login.name]
57 | # If the primary key does not exist, the method raises a `NotFoundError`.
58 | # Here we use this to just generate a user -- in practice you'd probably to redirect to a signup page.
59 | except NotFoundError: u = users.insert(login)
60 | if not compare_digest(u.pwd.encode("utf-8"), login.pwd.encode("utf-8")): return login_redir
61 | # Because the session is signed, we can securely add information to it. It's stored in the browser cookies.
62 | # If you don't pass a secret signing key to `FastHTML`, it will auto-generate one and store it in a file `./sesskey`.
63 | sess['auth'] = u.name
64 | return RedirectResponse('/', status_code=303)
65 |
66 | # Instead of using `app.route` (or the `rt` shortcut), you can also use `app.get`, `app.post`, etc.
67 | # In this case, the function name is not used to determine the HTTP verb.
68 | @app.get("/logout")
69 | def logout(sess):
70 | del sess['auth']
71 | return login_redir
72 |
73 | @rt("/{fname:path}.{ext:static}")
74 | def get(fname:str, ext:str): return FileResponse(f'{fname}.{ext}')
75 |
76 | # The `patch` decorator, which is defined in `fastcore`, adds a method to an existing class.
77 | # Here we are adding a method to the `Todo` class, which is returned by the `todos` table.
78 | # The `__ft__` method is a special method that FastHTML uses to convert the object into an `FT` object,
79 | # so that it can be composed into an FT tree, and later rendered into HTML.
80 | @patch
81 | def __ft__(self:Todo):
82 | show = AX(self.title, f'/todos/{self.id}', 'current-todo')
83 | edit = AX('edit', f'/edit/{self.id}' , 'current-todo')
84 | dt = '✅ ' if self.done else ''
85 | cts = (dt, show, ' | ', edit, Hidden(id="id", value=self.id), Hidden(id="priority", value="0"))
86 | return Li(*cts, id=f'todo-{self.id}')
87 |
88 | # This is the handler for the main todo list application.
89 | # By including the `auth` parameter, it gets passed the current username, for displaying in the title.
90 | @rt("/")
91 | def get(auth):
92 | title = f"{auth}'s Todo list"
93 | top = Grid(H1(title), Div(A('logout', href='/logout'), style='text-align: right'))
94 | # We don't normally need separate "screens" for adding or editing data. Here for instance,
95 | # we're using an `hx-post` to add a new todo, which is added to the start of the list (using 'afterbegin').
96 | new_inp = Input(id="new-title", name="title", placeholder="New Todo")
97 | add = Form(Group(new_inp, Button("Add")),
98 | hx_post="/", target_id='todo-list', hx_swap="afterbegin")
99 | # In the MiniDataAPI spec, treating a table as a callable (i.e with `todos(...)` here) queries the table.
100 | # Because we called `xtra` in our Beforeware, this queries the todos for the current user only.
101 | # We can include the todo objects directly as children of the `Form`, because the `Todo` class has `__ft__` defined.
102 | # This is automatically called by FastHTML to convert the `Todo` objects into `FT` objects when needed.
103 | # The reason we put the todo list inside a form is so that we can use the 'sortable' js library to reorder them.
104 | # That library calls the js `end` event when dragging is complete, so our trigger here causes our `/reorder`
105 | # handler to be called.
106 | frm = Form(*todos(order_by='priority'),
107 | id='todo-list', cls='sortable', hx_post="/reorder", hx_trigger="end")
108 | # We create an empty 'current-todo' Div at the bottom of our page, as a target for the details and editing views.
109 | card = Card(Ul(frm), header=add, footer=Div(id='current-todo'))
110 | # PicoCSS uses `` page content; `Container` is a tiny function that generates that.
111 | # A handler can return either a single `FT` object or string, or a tuple of them.
112 | # In the case of a tuple, the stringified objects are concatenated and returned to the browser.
113 | # The `Title` tag has a special purpose: it sets the title of the page.
114 | return Title(title), Container(top, card)
115 |
116 | @rt("/reorder")
117 | def post(id:list[int]):
118 | for i,id_ in enumerate(id): todos.update({'priority':i}, id_)
119 | # HTMX by default replaces the inner HTML of the calling element, which in this case is the todo list form.
120 | # Therefore, we return the list of todos, now in the correct order, which will be auto-converted to FT for us.
121 | # In this case, it's not strictly necessary, because sortable.js has already reorder the DOM elements.
122 | # However, by returning the updated data, we can be assured that there aren't sync issues between the DOM
123 | # and the server.
124 | return tuple(todos(order_by='priority'))
125 |
126 | # Refactoring components in FastHTML is as simple as creating Python functions.
127 | # The `clr_details` function creates a Div with specific HTMX attributes.
128 | # `hx_swap_oob='innerHTML'` tells HTMX to swap the inner HTML of the target element out-of-band,
129 | # meaning it will update this element regardless of where the HTMX request originated from.
130 | def clr_details(): return Div(hx_swap_oob='innerHTML', id='current-todo')
131 |
132 | # This route handler uses a path parameter `{id}` which is automatically parsed and passed as an int.
133 | @rt("/todos/{id}")
134 | def delete(id:int):
135 | # The `delete` method is part of the MiniDataAPI spec, removing the item with the given primary key.
136 | todos.delete(id)
137 | # Returning `clr_details()` ensures the details view is cleared after deletion,
138 | # leveraging HTMX's out-of-band swap feature.
139 | # Note that we are not returning *any* FT component that doesn't have an "OOB" swap, so the target element
140 | # inner HTML is simply deleted. That's why the deleted todo is removed from the list.
141 | return clr_details()
142 |
143 | @rt("/edit/{id}")
144 | def get(id:int):
145 | # The `hx_put` attribute tells HTMX to send a PUT request when the form is submitted.
146 | # `target_id` specifies which element will be updated with the server's response.
147 | res = Form(Group(Input(id="title"), Button("Save")),
148 | Hidden(id="id"), CheckboxX(id="done", label='Done'),
149 | Textarea(id="details", name="details", rows=10),
150 | hx_put="/", target_id=f'todo-{id}', id="edit")
151 | # `fill_form` populates the form with existing todo data, and returns the result.
152 | # Indexing into a table (`todos`) queries by primary key, which is `id` here. It also includes
153 | # `xtra`, so this will only return the id if it belongs to the current user.
154 | return fill_form(res, todos[id])
155 |
156 | @rt("/")
157 | def put(todo: Todo):
158 | # `update` is part of the MiniDataAPI spec.
159 | # Note that the updated todo is returned. By returning the updated todo, we can update the list directly.
160 | # Because we return a tuple with `clr_details()`, the details view is also cleared.
161 | return todos.update(todo), clr_details()
162 |
163 | @rt("/")
164 | def post(todo:Todo):
165 | # `hx_swap_oob='true'` tells HTMX to perform an out-of-band swap, updating this element wherever it appears.
166 | # This is used to clear the input field after adding the new todo.
167 | new_inp = Input(id="new-title", name="title", placeholder="New Todo", hx_swap_oob='true')
168 | # `insert` returns the inserted todo, which is appended to the start of the list, because we used
169 | # `hx_swap='afterbegin'` when creating the todo list form.
170 | return todos.insert(todo), new_inp
171 |
172 | @rt("/todos/{id}")
173 | def get(id:int):
174 | todo = todos[id]
175 | # `hx_swap` determines how the update should occur. We use "outerHTML" to replace the entire todo `Li` element.
176 | btn = Button('delete', hx_delete=f'/todos/{todo.id}',
177 | target_id=f'todo-{todo.id}', hx_swap="outerHTML")
178 | # The "markdown" class is used here because that's the CSS selector we used in the JS earlier.
179 | # Therefore this will trigger the JS to parse the markdown in the details field.
180 | # Because `class` is a reserved keyword in Python, we use `cls` instead, which FastHTML auto-converts.
181 | return Div(H2(todo.title), Div(todo.details, cls="markdown"), btn)
182 |
183 | # Start the Python web server, SQLite web viewer/editor server, and the Jupyter Lab server
184 | def serve_dev(db_path='data/utodos.db', port=8083):
185 | sqlite_process = subprocess.Popen(
186 | ['sqlite_web', db_path, '--port', str(port), '--no-browser'],
187 | stdout=subprocess.DEVNULL,
188 | stderr=subprocess.DEVNULL
189 | )
190 |
191 | # Capture the output of the Jupyter Lab process
192 | jupyter_process = subprocess.Popen(
193 | ['jupyter', 'lab', '--no-browser', '--NotebookApp.token=', '--NotebookApp.password='],
194 | stdout=subprocess.PIPE,
195 | stderr=subprocess.PIPE,
196 | text=True
197 | )
198 |
199 | # Extract and print the Jupyter Lab URL
200 | for line in jupyter_process.stderr:
201 | if 'http://' in line:
202 | match = re.search(r'(http://localhost:\d+/lab)', line)
203 | if match:
204 | print(f'Jupyter Lab: {match.group(1)}')
205 | break
206 |
207 | try:
208 | print(f'SQLite: http://localhost:{port}')
209 | serve()
210 | finally:
211 | sqlite_process.terminate()
212 | jupyter_process.terminate()
213 |
214 | serve_dev()
215 | #serve()
216 |
--------------------------------------------------------------------------------
/03-todo-multi-users/main.ipynb:
--------------------------------------------------------------------------------
1 | {
2 | "cells": [
3 | {
4 | "cell_type": "code",
5 | "execution_count": 1,
6 | "id": "23899eee-df2b-49b6-beec-062244b0394c",
7 | "metadata": {},
8 | "outputs": [],
9 | "source": [
10 | "from fasthtml.common import *\n",
11 | "from fastlite import *\n",
12 | "from fastcore.utils import *\n",
13 | "from fastcore.net import urlsave"
14 | ]
15 | },
16 | {
17 | "cell_type": "code",
18 | "execution_count": 2,
19 | "id": "7ab9e946-268f-4010-ae8d-60ca6de4b94e",
20 | "metadata": {},
21 | "outputs": [],
22 | "source": [
23 | "db = Database(\"data/utodos.db\")"
24 | ]
25 | },
26 | {
27 | "cell_type": "code",
28 | "execution_count": 3,
29 | "id": "d9d0338e-d5f9-43d0-8ffb-46883591c15d",
30 | "metadata": {},
31 | "outputs": [
32 | {
33 | "data": {
34 | "image/svg+xml": [
35 | "\n",
36 | "\n",
38 | "\n",
40 | "\n",
41 | "\n"
84 | ],
85 | "text/plain": [
86 | ""
87 | ]
88 | },
89 | "execution_count": 3,
90 | "metadata": {},
91 | "output_type": "execute_result"
92 | }
93 | ],
94 | "source": [
95 | "diagram(db.tables)"
96 | ]
97 | },
98 | {
99 | "cell_type": "code",
100 | "execution_count": 4,
101 | "id": "2d1e10c8-3c36-4b3d-8135-f76a849dd00f",
102 | "metadata": {},
103 | "outputs": [
104 | {
105 | "data": {
106 | "text/plain": [
107 | "todos, users"
108 | ]
109 | },
110 | "execution_count": 4,
111 | "metadata": {},
112 | "output_type": "execute_result"
113 | }
114 | ],
115 | "source": [
116 | "db.t"
117 | ]
118 | },
119 | {
120 | "cell_type": "code",
121 | "execution_count": 5,
122 | "id": "54004317-e231-4f6f-85ce-aec53f36782e",
123 | "metadata": {},
124 | "outputs": [
125 | {
126 | "data": {
127 | "text/plain": [
128 | "id, name, pwd"
129 | ]
130 | },
131 | "execution_count": 5,
132 | "metadata": {},
133 | "output_type": "execute_result"
134 | }
135 | ],
136 | "source": [
137 | "db.t['users'].c"
138 | ]
139 | },
140 | {
141 | "cell_type": "code",
142 | "execution_count": 6,
143 | "id": "85a54cb5-e87c-4a45-907c-8eb3c24b1901",
144 | "metadata": {},
145 | "outputs": [
146 | {
147 | "data": {
148 | "text/plain": [
149 | ""
150 | ]
151 | },
152 | "execution_count": 6,
153 | "metadata": {},
154 | "output_type": "execute_result"
155 | }
156 | ],
157 | "source": [
158 | "db.t['users']"
159 | ]
160 | },
161 | {
162 | "cell_type": "code",
163 | "execution_count": 26,
164 | "id": "fd947b2d-e820-4f57-9343-494055ce98b7",
165 | "metadata": {},
166 | "outputs": [
167 | {
168 | "data": {
169 | "text/plain": [
170 | ">"
171 | ]
172 | },
173 | "execution_count": 26,
174 | "metadata": {},
175 | "output_type": "execute_result"
176 | }
177 | ],
178 | "source": [
179 | "db"
180 | ]
181 | },
182 | {
183 | "cell_type": "code",
184 | "execution_count": 27,
185 | "id": "f6d52f10-5424-4b4a-b324-b17c52f434c6",
186 | "metadata": {},
187 | "outputs": [
188 | {
189 | "data": {
190 | "text/plain": [
191 | "todos, users"
192 | ]
193 | },
194 | "execution_count": 27,
195 | "metadata": {},
196 | "output_type": "execute_result"
197 | }
198 | ],
199 | "source": [
200 | "db.t"
201 | ]
202 | },
203 | {
204 | "cell_type": "code",
205 | "execution_count": 30,
206 | "id": "7e753593-2123-438f-8dd1-b67553e1317d",
207 | "metadata": {},
208 | "outputs": [],
209 | "source": [
210 | "#db.t['test'].drop()"
211 | ]
212 | },
213 | {
214 | "cell_type": "code",
215 | "execution_count": 31,
216 | "id": "69c22055-c37e-43ac-b798-51e12ee9e9de",
217 | "metadata": {},
218 | "outputs": [
219 | {
220 | "data": {
221 | "text/markdown": [
222 | "```html\n",
223 | "Hello
\n",
224 | "\n",
225 | "```"
226 | ],
227 | "text/plain": [
228 | "div(('Hello',),{})"
229 | ]
230 | },
231 | "execution_count": 31,
232 | "metadata": {},
233 | "output_type": "execute_result"
234 | }
235 | ],
236 | "source": [
237 | "Div('Hello')"
238 | ]
239 | },
240 | {
241 | "cell_type": "code",
242 | "execution_count": 32,
243 | "id": "02a29b73-7d22-4afc-bce8-a4b7bcae1f63",
244 | "metadata": {},
245 | "outputs": [],
246 | "source": [
247 | "users = db.t.users"
248 | ]
249 | },
250 | {
251 | "cell_type": "code",
252 | "execution_count": 33,
253 | "id": "30aa515d-4c7c-46ee-a416-1aff8fef1f18",
254 | "metadata": {},
255 | "outputs": [
256 | {
257 | "data": {
258 | "text/plain": [
259 | "[{'id': 1, 'name': 'dg', 'pwd': '1234'}]"
260 | ]
261 | },
262 | "execution_count": 33,
263 | "metadata": {},
264 | "output_type": "execute_result"
265 | }
266 | ],
267 | "source": [
268 | "users()"
269 | ]
270 | },
271 | {
272 | "cell_type": "code",
273 | "execution_count": 34,
274 | "id": "04eceb53-d547-4f09-8f52-6dd2fa806e28",
275 | "metadata": {},
276 | "outputs": [
277 | {
278 | "data": {
279 | "text/plain": [
280 | "[{'id': 1,\n",
281 | " 'title': 'one',\n",
282 | " 'done': None,\n",
283 | " 'name': 'dg',\n",
284 | " 'details': None,\n",
285 | " 'priority': None},\n",
286 | " {'id': 2,\n",
287 | " 'title': 'two',\n",
288 | " 'done': None,\n",
289 | " 'name': 'dg',\n",
290 | " 'details': None,\n",
291 | " 'priority': None},\n",
292 | " {'id': 3,\n",
293 | " 'title': '1',\n",
294 | " 'done': None,\n",
295 | " 'name': 'dg',\n",
296 | " 'details': None,\n",
297 | " 'priority': None},\n",
298 | " {'id': 4,\n",
299 | " 'title': '2',\n",
300 | " 'done': None,\n",
301 | " 'name': 'dg',\n",
302 | " 'details': None,\n",
303 | " 'priority': None},\n",
304 | " {'id': 5,\n",
305 | " 'title': '3',\n",
306 | " 'done': None,\n",
307 | " 'name': 'dg',\n",
308 | " 'details': None,\n",
309 | " 'priority': None},\n",
310 | " {'id': 6,\n",
311 | " 'title': '34',\n",
312 | " 'done': None,\n",
313 | " 'name': 'dg',\n",
314 | " 'details': None,\n",
315 | " 'priority': None},\n",
316 | " {'id': 7,\n",
317 | " 'title': '5',\n",
318 | " 'done': None,\n",
319 | " 'name': 'dg',\n",
320 | " 'details': None,\n",
321 | " 'priority': None},\n",
322 | " {'id': 8,\n",
323 | " 'title': '5',\n",
324 | " 'done': None,\n",
325 | " 'name': 'dg',\n",
326 | " 'details': None,\n",
327 | " 'priority': None},\n",
328 | " {'id': 9,\n",
329 | " 'title': '6',\n",
330 | " 'done': None,\n",
331 | " 'name': 'dg',\n",
332 | " 'details': None,\n",
333 | " 'priority': None},\n",
334 | " {'id': 10,\n",
335 | " 'title': '6',\n",
336 | " 'done': None,\n",
337 | " 'name': 'dg',\n",
338 | " 'details': None,\n",
339 | " 'priority': None},\n",
340 | " {'id': 11,\n",
341 | " 'title': '6',\n",
342 | " 'done': None,\n",
343 | " 'name': 'dg',\n",
344 | " 'details': None,\n",
345 | " 'priority': None}]"
346 | ]
347 | },
348 | "execution_count": 34,
349 | "metadata": {},
350 | "output_type": "execute_result"
351 | }
352 | ],
353 | "source": [
354 | "todos = db.t.todos\n",
355 | "todos()"
356 | ]
357 | },
358 | {
359 | "cell_type": "code",
360 | "execution_count": null,
361 | "id": "f12445fc-090e-40c7-b486-a14bd4702b97",
362 | "metadata": {},
363 | "outputs": [],
364 | "source": []
365 | }
366 | ],
367 | "metadata": {
368 | "kernelspec": {
369 | "display_name": "Python 3 (ipykernel)",
370 | "language": "python",
371 | "name": "python3"
372 | },
373 | "language_info": {
374 | "codemirror_mode": {
375 | "name": "ipython",
376 | "version": 3
377 | },
378 | "file_extension": ".py",
379 | "mimetype": "text/x-python",
380 | "name": "python",
381 | "nbconvert_exporter": "python",
382 | "pygments_lexer": "ipython3",
383 | "version": "3.12.5"
384 | }
385 | },
386 | "nbformat": 4,
387 | "nbformat_minor": 5
388 | }
389 |
--------------------------------------------------------------------------------
/03-todo-multi-users/main.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from hmac import compare_digest
3 | import subprocess
4 |
5 | db = database('data/utodos.db')
6 |
7 | todos,users = db.t.todos,db.t.users
8 | if todos not in db.t:
9 | users.create(dict(id=int, name=str, pwd=str), pk='id')
10 | todos.create(id=int, title=str, done=bool, name=str, details=str, priority=int, pk='id')
11 | Todo,User = todos.dataclass(),users.dataclass()
12 |
13 | # Status code 303 is a redirect that can change POST to GET, so it's appropriate for a login page.
14 | login_redir = RedirectResponse('/login', status_code=303)
15 |
16 | # The `before` function is a *Beforeware* function. These are functions that run before a route handler is called.
17 | def before(req, sess):
18 | auth = req.scope['auth'] = sess.get('auth', None)
19 | if not auth: return login_redir
20 | todos.xtra(name=auth)
21 |
22 | markdown_js = """
23 | import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
24 | proc_htmx('.markdown', e => e.innerHTML = marked.parse(e.textContent));
25 | """
26 |
27 | # We will use this in our `exception_handlers` dict
28 | def _not_found(req, exc): return Titled('Oh no!', Div('We could not find that page :('))
29 |
30 | # To create a Beforeware object, we pass the function itself, and optionally a list of regexes to skip.
31 | bware = Beforeware(before, skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', '/login'])
32 | app = FastHTMLWithLiveReload(before=bware,
33 | exception_handlers={404: _not_found},
34 | pico=False,
35 | hdrs=(
36 | Style(':root { --pico-font-size: 100%; }'),
37 | SortableJS('.sortable'),
38 | Script(markdown_js, type='module'),
39 | Link(rel="stylesheet", href="/public/app.css", type="text/css"),
40 | )
41 | )
42 | rt = app.route
43 |
44 | @rt("/login")
45 | def get():
46 | frm = Form(
47 | Input(id='name', placeholder='Name'),
48 | Input(id='pwd', type='password', placeholder='Password'),
49 | Button('login'),
50 | action='/login', method='post')
51 | return Titled("Login", frm)
52 |
53 | @dataclass
54 | class Login: name:str; pwd:str
55 |
56 | @rt("/login")
57 | def post(login:Login, sess):
58 | if not login.name or not login.pwd: return login_redir
59 | try: u = users[login.name]
60 | # If the primary key does not exist, the method raises a `NotFoundError`.
61 | # Here we use this to just generate a user -- in practice you'd probably to redirect to a signup page.
62 | except NotFoundError: u = users.insert(login)
63 | if not compare_digest(u.pwd.encode("utf-8"), login.pwd.encode("utf-8")): return login_redir
64 | # Because the session is signed, we can securely add information to it. It's stored in the browser cookies.
65 | # If you don't pass a secret signing key to `FastHTML`, it will auto-generate one and store it in a file `./sesskey`.
66 | sess['auth'] = u.name
67 | return RedirectResponse('/', status_code=303)
68 |
69 | # Instead of using `app.route` (or the `rt` shortcut), you can also use `app.get`, `app.post`, etc.
70 | # In this case, the function name is not used to determine the HTTP verb.
71 | @app.get("/logout")
72 | def logout(sess):
73 | del sess['auth']
74 | return login_redir
75 |
76 | @rt("/{fname:path}.{ext:static}")
77 | def get(fname:str, ext:str): return FileResponse(f'{fname}.{ext}')
78 |
79 | # The `patch` decorator, which is defined in `fastcore`, adds a method to an existing class.
80 | # Here we are adding a method to the `Todo` class, which is returned by the `todos` table.
81 | # The `__ft__` method is a special method that FastHTML uses to convert the object into an `FT` object,
82 | # so that it can be composed into an FT tree, and later rendered into HTML.
83 | @patch
84 | def __ft__(self:Todo):
85 | show = AX(self.title, f'/todos/{self.id}', 'current-todo')
86 | edit = AX('edit', f'/edit/{self.id}' , 'current-todo')
87 | dt = '✅ ' if self.done else ''
88 | cts = (dt, show, ' | ', edit, Hidden(id="id", value=self.id), Hidden(id="priority", value="0"))
89 | return Li(*cts, id=f'todo-{self.id}')
90 |
91 | # This is the handler for the main todo list application.
92 | # By including the `auth` parameter, it gets passed the current username, for displaying in the title.
93 | @rt("/")
94 | def get(auth):
95 | title = f"{auth}'s Todo list"
96 | top = Grid(H1(title), Div(A('logout', href='/logout'), style='text-align: right'))
97 | # We don't normally need separate "screens" for adding or editing data. Here for instance,
98 | # we're using an `hx-post` to add a new todo, which is added to the start of the list (using 'afterbegin').
99 | new_inp = Input(id="new-title", name="title", placeholder="New Todo")
100 | add = Form(Group(new_inp, Button("Add")),
101 | hx_post="/", target_id='todo-list', hx_swap="afterbegin")
102 | # In the MiniDataAPI spec, treating a table as a callable (i.e with `todos(...)` here) queries the table.
103 | # Because we called `xtra` in our Beforeware, this queries the todos for the current user only.
104 | # We can include the todo objects directly as children of the `Form`, because the `Todo` class has `__ft__` defined.
105 | # This is automatically called by FastHTML to convert the `Todo` objects into `FT` objects when needed.
106 | # The reason we put the todo list inside a form is so that we can use the 'sortable' js library to reorder them.
107 | # That library calls the js `end` event when dragging is complete, so our trigger here causes our `/reorder`
108 | # handler to be called.
109 | frm = Form(*todos(order_by='priority'),
110 | id='todo-list', cls='sortable', hx_post="/reorder", hx_trigger="end")
111 | # We create an empty 'current-todo' Div at the bottom of our page, as a target for the details and editing views.
112 | card = Card(Ul(frm), header=add, footer=Div(id='current-todo'))
113 | # PicoCSS uses `` page content; `Container` is a tiny function that generates that.
114 | # A handler can return either a single `FT` object or string, or a tuple of them.
115 | # In the case of a tuple, the stringified objects are concatenated and returned to the browser.
116 | # The `Title` tag has a special purpose: it sets the title of the page.
117 | return Title(title), Div('Hello', cls="test"), Div('Hello', cls="test1"), Container(top, card)
118 |
119 | @rt("/reorder")
120 | def post(id:list[int]):
121 | for i,id_ in enumerate(id): todos.update({'priority':i}, id_)
122 | # HTMX by default replaces the inner HTML of the calling element, which in this case is the todo list form.
123 | # Therefore, we return the list of todos, now in the correct order, which will be auto-converted to FT for us.
124 | # In this case, it's not strictly necessary, because sortable.js has already reorder the DOM elements.
125 | # However, by returning the updated data, we can be assured that there aren't sync issues between the DOM
126 | # and the server.
127 | return tuple(todos(order_by='priority'))
128 |
129 | # Refactoring components in FastHTML is as simple as creating Python functions.
130 | # The `clr_details` function creates a Div with specific HTMX attributes.
131 | # `hx_swap_oob='innerHTML'` tells HTMX to swap the inner HTML of the target element out-of-band,
132 | # meaning it will update this element regardless of where the HTMX request originated from.
133 | def clr_details(): return Div(hx_swap_oob='innerHTML', id='current-todo')
134 |
135 | # This route handler uses a path parameter `{id}` which is automatically parsed and passed as an int.
136 | @rt("/todos/{id}")
137 | def delete(id:int):
138 | # The `delete` method is part of the MiniDataAPI spec, removing the item with the given primary key.
139 | todos.delete(id)
140 | # Returning `clr_details()` ensures the details view is cleared after deletion,
141 | # leveraging HTMX's out-of-band swap feature.
142 | # Note that we are not returning *any* FT component that doesn't have an "OOB" swap, so the target element
143 | # inner HTML is simply deleted. That's why the deleted todo is removed from the list.
144 | return clr_details()
145 |
146 | @rt("/edit/{id}")
147 | def get(id:int):
148 | # The `hx_put` attribute tells HTMX to send a PUT request when the form is submitted.
149 | # `target_id` specifies which element will be updated with the server's response.
150 | res = Form(Group(Input(id="title"), Button("Save")),
151 | Hidden(id="id"), CheckboxX(id="done", label='Done'),
152 | Textarea(id="details", name="details", rows=10),
153 | hx_put="/", target_id=f'todo-{id}', id="edit")
154 | # `fill_form` populates the form with existing todo data, and returns the result.
155 | # Indexing into a table (`todos`) queries by primary key, which is `id` here. It also includes
156 | # `xtra`, so this will only return the id if it belongs to the current user.
157 | return fill_form(res, todos[id])
158 |
159 | @rt("/")
160 | def put(todo: Todo):
161 | # `update` is part of the MiniDataAPI spec.
162 | # Note that the updated todo is returned. By returning the updated todo, we can update the list directly.
163 | # Because we return a tuple with `clr_details()`, the details view is also cleared.
164 | return todos.update(todo), clr_details()
165 |
166 | @rt("/")
167 | def post(todo:Todo):
168 | # `hx_swap_oob='true'` tells HTMX to perform an out-of-band swap, updating this element wherever it appears.
169 | # This is used to clear the input field after adding the new todo.
170 | new_inp = Input(id="new-title", name="title", placeholder="New Todo", hx_swap_oob='true')
171 | # `insert` returns the inserted todo, which is appended to the start of the list, because we used
172 | # `hx_swap='afterbegin'` when creating the todo list form.
173 | return todos.insert(todo), new_inp
174 |
175 | @rt("/todos/{id}")
176 | def get(id:int):
177 | todo = todos[id]
178 | # `hx_swap` determines how the update should occur. We use "outerHTML" to replace the entire todo `Li` element.
179 | btn = Button('delete', hx_delete=f'/todos/{todo.id}',
180 | target_id=f'todo-{todo.id}', hx_swap="outerHTML")
181 | # The "markdown" class is used here because that's the CSS selector we used in the JS earlier.
182 | # Therefore this will trigger the JS to parse the markdown in the details field.
183 | # Because `class` is a reserved keyword in Python, we use `cls` instead, which FastHTML auto-converts.
184 | return Div(H2(todo.title), Div(todo.details, cls="markdown"), btn)
185 |
186 | # Start the Python web server, SQLite editor, Jupyter Lab server, and Tailwind CSS compiler.
187 | # Todo:
188 | # - Be able to disable any of the servers
189 | # - Maybe just mirror server() unless you specify the servers to run. i.e. opt in.
190 | # - Test to see if the re. Python libraries are installed. If not, prompt to install.
191 | def serve_dev(db_path='data/utodos.db', sqlite_port=8090, jupyter_port=8091, tw_src='./src/app.css', tw_dist='./public/app.css'):
192 | sqlite_process = subprocess.Popen(
193 | ['sqlite_web', db_path, '--port', str(sqlite_port), '--no-browser'],
194 | stdout=subprocess.DEVNULL,
195 | stderr=subprocess.DEVNULL
196 | )
197 |
198 | jupyter_process = subprocess.Popen(
199 | ['jupyter', 'lab', '--port', str(jupyter_port), '--no-browser', '--NotebookApp.token=', '--NotebookApp.password='],
200 | stdout=subprocess.PIPE,
201 | stderr=subprocess.PIPE,
202 | text=True
203 | )
204 |
205 | tailwind_process = subprocess.Popen(
206 | ['tailwindcss', '-i', tw_src, '-o', tw_dist, '--watch'],
207 | stdout=subprocess.DEVNULL,
208 | stderr=subprocess.DEVNULL
209 | )
210 |
211 | # Extract and print the Jupyter Lab URL
212 | for line in jupyter_process.stderr:
213 | if 'http://' in line:
214 | match = re.search(r'(http://localhost:\d+/lab)', line)
215 | if match:
216 | print(f'Jupyter Lab: {match.group(1)}')
217 | break
218 |
219 | try:
220 | print(f'SQLite: http://localhost:{sqlite_port}')
221 | serve(reload_includes=["*.css"])
222 | finally:
223 | sqlite_process.terminate()
224 | jupyter_process.terminate()
225 | tailwind_process.terminate()
226 |
227 | serve_dev()
228 | #serve()
229 |
--------------------------------------------------------------------------------
/03-todo-multi-users/public/app.css:
--------------------------------------------------------------------------------
1 | /*
2 | ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com
3 | */
4 |
5 | /*
6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
8 | */
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | /* 1 */
15 | border-width: 0;
16 | /* 2 */
17 | border-style: solid;
18 | /* 2 */
19 | border-color: #e5e7eb;
20 | /* 2 */
21 | }
22 |
23 | ::before,
24 | ::after {
25 | --tw-content: '';
26 | }
27 |
28 | /*
29 | 1. Use a consistent sensible line-height in all browsers.
30 | 2. Prevent adjustments of font size after orientation changes in iOS.
31 | 3. Use a more readable tab size.
32 | 4. Use the user's configured `sans` font-family by default.
33 | 5. Use the user's configured `sans` font-feature-settings by default.
34 | */
35 |
36 | html {
37 | line-height: 1.5;
38 | /* 1 */
39 | -webkit-text-size-adjust: 100%;
40 | /* 2 */
41 | -moz-tab-size: 4;
42 | /* 3 */
43 | -o-tab-size: 4;
44 | tab-size: 4;
45 | /* 3 */
46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47 | /* 4 */
48 | font-feature-settings: normal;
49 | /* 5 */
50 | }
51 |
52 | /*
53 | 1. Remove the margin in all browsers.
54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
55 | */
56 |
57 | body {
58 | margin: 0;
59 | /* 1 */
60 | line-height: inherit;
61 | /* 2 */
62 | }
63 |
64 | /*
65 | 1. Add the correct height in Firefox.
66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
67 | 3. Ensure horizontal rules are visible by default.
68 | */
69 |
70 | hr {
71 | height: 0;
72 | /* 1 */
73 | color: inherit;
74 | /* 2 */
75 | border-top-width: 1px;
76 | /* 3 */
77 | }
78 |
79 | /*
80 | Add the correct text decoration in Chrome, Edge, and Safari.
81 | */
82 |
83 | abbr:where([title]) {
84 | -webkit-text-decoration: underline dotted;
85 | text-decoration: underline dotted;
86 | }
87 |
88 | /*
89 | Remove the default font size and weight for headings.
90 | */
91 |
92 | h1,
93 | h2,
94 | h3,
95 | h4,
96 | h5,
97 | h6 {
98 | font-size: inherit;
99 | font-weight: inherit;
100 | }
101 |
102 | /*
103 | Reset links to optimize for opt-in styling instead of opt-out.
104 | */
105 |
106 | a {
107 | color: inherit;
108 | text-decoration: inherit;
109 | }
110 |
111 | /*
112 | Add the correct font weight in Edge and Safari.
113 | */
114 |
115 | b,
116 | strong {
117 | font-weight: bolder;
118 | }
119 |
120 | /*
121 | 1. Use the user's configured `mono` font family by default.
122 | 2. Correct the odd `em` font sizing in all browsers.
123 | */
124 |
125 | code,
126 | kbd,
127 | samp,
128 | pre {
129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
130 | /* 1 */
131 | font-size: 1em;
132 | /* 2 */
133 | }
134 |
135 | /*
136 | Add the correct font size in all browsers.
137 | */
138 |
139 | small {
140 | font-size: 80%;
141 | }
142 |
143 | /*
144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
145 | */
146 |
147 | sub,
148 | sup {
149 | font-size: 75%;
150 | line-height: 0;
151 | position: relative;
152 | vertical-align: baseline;
153 | }
154 |
155 | sub {
156 | bottom: -0.25em;
157 | }
158 |
159 | sup {
160 | top: -0.5em;
161 | }
162 |
163 | /*
164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
166 | 3. Remove gaps between table borders by default.
167 | */
168 |
169 | table {
170 | text-indent: 0;
171 | /* 1 */
172 | border-color: inherit;
173 | /* 2 */
174 | border-collapse: collapse;
175 | /* 3 */
176 | }
177 |
178 | /*
179 | 1. Change the font styles in all browsers.
180 | 2. Remove the margin in Firefox and Safari.
181 | 3. Remove default padding in all browsers.
182 | */
183 |
184 | button,
185 | input,
186 | optgroup,
187 | select,
188 | textarea {
189 | font-family: inherit;
190 | /* 1 */
191 | font-size: 100%;
192 | /* 1 */
193 | font-weight: inherit;
194 | /* 1 */
195 | line-height: inherit;
196 | /* 1 */
197 | color: inherit;
198 | /* 1 */
199 | margin: 0;
200 | /* 2 */
201 | padding: 0;
202 | /* 3 */
203 | }
204 |
205 | /*
206 | Remove the inheritance of text transform in Edge and Firefox.
207 | */
208 |
209 | button,
210 | select {
211 | text-transform: none;
212 | }
213 |
214 | /*
215 | 1. Correct the inability to style clickable types in iOS and Safari.
216 | 2. Remove default button styles.
217 | */
218 |
219 | button,
220 | [type='button'],
221 | [type='reset'],
222 | [type='submit'] {
223 | -webkit-appearance: button;
224 | /* 1 */
225 | background-color: transparent;
226 | /* 2 */
227 | background-image: none;
228 | /* 2 */
229 | }
230 |
231 | /*
232 | Use the modern Firefox focus style for all focusable elements.
233 | */
234 |
235 | :-moz-focusring {
236 | outline: auto;
237 | }
238 |
239 | /*
240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
241 | */
242 |
243 | :-moz-ui-invalid {
244 | box-shadow: none;
245 | }
246 |
247 | /*
248 | Add the correct vertical alignment in Chrome and Firefox.
249 | */
250 |
251 | progress {
252 | vertical-align: baseline;
253 | }
254 |
255 | /*
256 | Correct the cursor style of increment and decrement buttons in Safari.
257 | */
258 |
259 | ::-webkit-inner-spin-button,
260 | ::-webkit-outer-spin-button {
261 | height: auto;
262 | }
263 |
264 | /*
265 | 1. Correct the odd appearance in Chrome and Safari.
266 | 2. Correct the outline style in Safari.
267 | */
268 |
269 | [type='search'] {
270 | -webkit-appearance: textfield;
271 | /* 1 */
272 | outline-offset: -2px;
273 | /* 2 */
274 | }
275 |
276 | /*
277 | Remove the inner padding in Chrome and Safari on macOS.
278 | */
279 |
280 | ::-webkit-search-decoration {
281 | -webkit-appearance: none;
282 | }
283 |
284 | /*
285 | 1. Correct the inability to style clickable types in iOS and Safari.
286 | 2. Change font properties to `inherit` in Safari.
287 | */
288 |
289 | ::-webkit-file-upload-button {
290 | -webkit-appearance: button;
291 | /* 1 */
292 | font: inherit;
293 | /* 2 */
294 | }
295 |
296 | /*
297 | Add the correct display in Chrome and Safari.
298 | */
299 |
300 | summary {
301 | display: list-item;
302 | }
303 |
304 | /*
305 | Removes the default spacing and border for appropriate elements.
306 | */
307 |
308 | blockquote,
309 | dl,
310 | dd,
311 | h1,
312 | h2,
313 | h3,
314 | h4,
315 | h5,
316 | h6,
317 | hr,
318 | figure,
319 | p,
320 | pre {
321 | margin: 0;
322 | }
323 |
324 | fieldset {
325 | margin: 0;
326 | padding: 0;
327 | }
328 |
329 | legend {
330 | padding: 0;
331 | }
332 |
333 | ol,
334 | ul,
335 | menu {
336 | list-style: none;
337 | margin: 0;
338 | padding: 0;
339 | }
340 |
341 | /*
342 | Prevent resizing textareas horizontally by default.
343 | */
344 |
345 | textarea {
346 | resize: vertical;
347 | }
348 |
349 | /*
350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
351 | 2. Set the default placeholder color to the user's configured gray 400 color.
352 | */
353 |
354 | input::-moz-placeholder, textarea::-moz-placeholder {
355 | opacity: 1;
356 | /* 1 */
357 | color: #9ca3af;
358 | /* 2 */
359 | }
360 |
361 | input::placeholder,
362 | textarea::placeholder {
363 | opacity: 1;
364 | /* 1 */
365 | color: #9ca3af;
366 | /* 2 */
367 | }
368 |
369 | /*
370 | Set the default cursor for buttons.
371 | */
372 |
373 | button,
374 | [role="button"] {
375 | cursor: pointer;
376 | }
377 |
378 | /*
379 | Make sure disabled buttons don't get the pointer cursor.
380 | */
381 |
382 | :disabled {
383 | cursor: default;
384 | }
385 |
386 | /*
387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
389 | This can trigger a poorly considered lint error in some tools but is included by design.
390 | */
391 |
392 | img,
393 | svg,
394 | video,
395 | canvas,
396 | audio,
397 | iframe,
398 | embed,
399 | object {
400 | display: block;
401 | /* 1 */
402 | vertical-align: middle;
403 | /* 2 */
404 | }
405 |
406 | /*
407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
408 | */
409 |
410 | img,
411 | video {
412 | max-width: 100%;
413 | height: auto;
414 | }
415 |
416 | /* Make elements with the HTML hidden attribute stay hidden by default */
417 |
418 | [hidden] {
419 | display: none;
420 | }
421 |
422 | *, ::before, ::after {
423 | --tw-border-spacing-x: 0;
424 | --tw-border-spacing-y: 0;
425 | --tw-translate-x: 0;
426 | --tw-translate-y: 0;
427 | --tw-rotate: 0;
428 | --tw-skew-x: 0;
429 | --tw-skew-y: 0;
430 | --tw-scale-x: 1;
431 | --tw-scale-y: 1;
432 | --tw-pan-x: ;
433 | --tw-pan-y: ;
434 | --tw-pinch-zoom: ;
435 | --tw-scroll-snap-strictness: proximity;
436 | --tw-ordinal: ;
437 | --tw-slashed-zero: ;
438 | --tw-numeric-figure: ;
439 | --tw-numeric-spacing: ;
440 | --tw-numeric-fraction: ;
441 | --tw-ring-inset: ;
442 | --tw-ring-offset-width: 0px;
443 | --tw-ring-offset-color: #fff;
444 | --tw-ring-color: rgb(59 130 246 / 0.5);
445 | --tw-ring-offset-shadow: 0 0 #0000;
446 | --tw-ring-shadow: 0 0 #0000;
447 | --tw-shadow: 0 0 #0000;
448 | --tw-shadow-colored: 0 0 #0000;
449 | --tw-blur: ;
450 | --tw-brightness: ;
451 | --tw-contrast: ;
452 | --tw-grayscale: ;
453 | --tw-hue-rotate: ;
454 | --tw-invert: ;
455 | --tw-saturate: ;
456 | --tw-sepia: ;
457 | --tw-drop-shadow: ;
458 | --tw-backdrop-blur: ;
459 | --tw-backdrop-brightness: ;
460 | --tw-backdrop-contrast: ;
461 | --tw-backdrop-grayscale: ;
462 | --tw-backdrop-hue-rotate: ;
463 | --tw-backdrop-invert: ;
464 | --tw-backdrop-opacity: ;
465 | --tw-backdrop-saturate: ;
466 | --tw-backdrop-sepia: ;
467 | }
468 |
469 | ::backdrop {
470 | --tw-border-spacing-x: 0;
471 | --tw-border-spacing-y: 0;
472 | --tw-translate-x: 0;
473 | --tw-translate-y: 0;
474 | --tw-rotate: 0;
475 | --tw-skew-x: 0;
476 | --tw-skew-y: 0;
477 | --tw-scale-x: 1;
478 | --tw-scale-y: 1;
479 | --tw-pan-x: ;
480 | --tw-pan-y: ;
481 | --tw-pinch-zoom: ;
482 | --tw-scroll-snap-strictness: proximity;
483 | --tw-ordinal: ;
484 | --tw-slashed-zero: ;
485 | --tw-numeric-figure: ;
486 | --tw-numeric-spacing: ;
487 | --tw-numeric-fraction: ;
488 | --tw-ring-inset: ;
489 | --tw-ring-offset-width: 0px;
490 | --tw-ring-offset-color: #fff;
491 | --tw-ring-color: rgb(59 130 246 / 0.5);
492 | --tw-ring-offset-shadow: 0 0 #0000;
493 | --tw-ring-shadow: 0 0 #0000;
494 | --tw-shadow: 0 0 #0000;
495 | --tw-shadow-colored: 0 0 #0000;
496 | --tw-blur: ;
497 | --tw-brightness: ;
498 | --tw-contrast: ;
499 | --tw-grayscale: ;
500 | --tw-hue-rotate: ;
501 | --tw-invert: ;
502 | --tw-saturate: ;
503 | --tw-sepia: ;
504 | --tw-drop-shadow: ;
505 | --tw-backdrop-blur: ;
506 | --tw-backdrop-brightness: ;
507 | --tw-backdrop-contrast: ;
508 | --tw-backdrop-grayscale: ;
509 | --tw-backdrop-hue-rotate: ;
510 | --tw-backdrop-invert: ;
511 | --tw-backdrop-opacity: ;
512 | --tw-backdrop-saturate: ;
513 | --tw-backdrop-sepia: ;
514 | }
515 |
516 | .container {
517 | width: 100%;
518 | }
519 |
520 | @media (min-width: 640px) {
521 | .container {
522 | max-width: 640px;
523 | }
524 | }
525 |
526 | @media (min-width: 768px) {
527 | .container {
528 | max-width: 768px;
529 | }
530 | }
531 |
532 | @media (min-width: 1024px) {
533 | .container {
534 | max-width: 1024px;
535 | }
536 | }
537 |
538 | @media (min-width: 1280px) {
539 | .container {
540 | max-width: 1280px;
541 | }
542 | }
543 |
544 | @media (min-width: 1536px) {
545 | .container {
546 | max-width: 1536px;
547 | }
548 | }
549 |
550 | .table {
551 | display: table;
552 | }
553 |
554 | .test {
555 | --tw-bg-opacity: 1;
556 | background-color: rgb(239 68 68 / var(--tw-bg-opacity));
557 | }
558 |
559 | .test1 {
560 | color: hotpink;
561 | }
562 |
--------------------------------------------------------------------------------
/03-todo-multi-users/requirements.txt:
--------------------------------------------------------------------------------
1 | python-fasthtml
2 | uvicorn>=0.29
3 | python-multipart
4 | sqlite-utils
5 | huggingface-hub>=0.20.0
6 | fasthtml-hf
7 | jupyterlab
8 | sqlite-web
9 | graphviz
10 |
--------------------------------------------------------------------------------
/03-todo-multi-users/src/app.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | .test {
6 | @apply bg-red-500;
7 | }
8 |
9 | .test1 {
10 | color: hotpink;
11 | }
12 |
--------------------------------------------------------------------------------
/03-todo-multi-users/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["**/*.py"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
33 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/data/main.db:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/04-sqlite-boilerplate/data/main.db
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/db.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from utils import login_redir, n_words
3 | from datetime import datetime
4 |
5 | db = database('data/main.db')
6 |
7 | todos,users = db.t.todos,db.t.users
8 | if todos not in db.t:
9 | users.create(dict(id=int, name=str, pwd=str), pk='id')
10 | todos.create(id=int, title=str, done=bool, user_id=int, details=str, date=str, priority=int, pk='id')
11 | Todo,User = todos.dataclass(),users.dataclass()
12 |
13 | # Beforeware cb function, run before a route handler is called.
14 | def before(req, sess):
15 | global todos
16 | auth = req.scope['auth'] = sess.get('auth', None)
17 | if not auth:
18 | sess['intended_url'] = req.url.path
19 | return login_redir
20 | todos.xtra(user_id=auth)
21 |
22 | # The `patch` decorator, which is defined in `fastcore`, adds a method to an existing class.
23 | # Here we are adding a method to the `Todo` class, which is returned by the `todos` table.
24 | # The `__ft__` method is a special method that FastHTML uses to convert the object into an `FT` object,
25 | # so that it can be composed into an FT tree, and later rendered into HTML.
26 | @patch
27 | def __ft__(self:Todo):
28 | show = AX(self.title, f'/todos/{self.id}', 'current-todo')
29 | edit = AX('Edit', f'/edit/{self.id}' , 'current-todo', cls='text-indigo-600 hover:text-indigo-900')
30 | dt = '✅ ' if self.done else '❌'
31 | timestamp = int(self.date)
32 | formatted_date = datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d')
33 |
34 | return Tr(
35 | Td(show, title="Display todo details", cls='py-4 pl-4 pr-3 text-sm font-medium text-gray-900 sm:pl-0'),
36 | Td(n_words(self.details, 5), cls='px-3 py-4 text-sm text-gray-500'),
37 | Td(dt, cls='px-3 py-4 text-sm text-gray-500'),
38 | Td(formatted_date, cls='px-3 py-4 text-sm text-gray-500'),
39 | Td(
40 | edit,
41 | cls='relative py-4 pl-3 pr-4 text-right text-sm font-medium sm:pr-0'
42 | ),
43 | id=f'todo-{self.id}'
44 | ),
45 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/main.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 | from hmac import compare_digest
3 | from datetime import datetime
4 | from templates import _404, clr_details, header, footer
5 | from utils import login_redir, home_redir, Login
6 | from db import before, todos, users, Todo, User, db
7 | from pprint import pprint
8 | import subprocess
9 |
10 | bware = Beforeware(before, skip=[r'/favicon\.ico', r'/assets/.*', r'.*\.css', '/', '/login'])
11 | app = FastHTMLWithLiveReload(before=bware,
12 | exception_handlers={404: _404},
13 | pico=False,
14 | hdrs=(
15 | Link(rel="stylesheet", href="/public/app.css", type="text/css"),
16 | )
17 | )
18 | rt = app.route
19 |
20 | ### Homepage Routes ###
21 |
22 | def header_html(sess):
23 | links = {'ToDos': '/todos', 'About': '/about', 'Contact': '/contact'}
24 |
25 | # Get the authenticated user from the session
26 | auth = sess.get('auth')
27 | if auth:
28 | user_name = sess.get('user_name')
29 | user_info = H2(f"Welcome, {user_name}!", cls="user-info")
30 | links['Logout'] = '/logout'
31 |
32 | else:
33 | user_info = H2("Welcome, Guest!", cls="user-info")
34 | links['Login'] = '/login'
35 |
36 | return (user_info, links)
37 |
38 | @rt("/")
39 | def get(sess):
40 | user_info, links = header_html(sess)
41 |
42 | return (
43 | header(links=links),
44 | Div(
45 | user_info,
46 | Div('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut ut consequat neque, vel luctus elit. Nunc elementum sapien nunc, vel efficitur urna malesuada nec. Fusce vulputate ornare congue. Proin vulputate lacus lorem, vitae dignissim massa luctus eget. Nam ante libero, ornare eu enim eu, vulputate suscipit nulla. Donec consectetur, dui vel malesuada ullamcorper, metus sem dignissim nunc, et varius nisl est sit amet nisl. Quisque placerat feugiat sapien, id vulputate turpis dignissim id. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec mauris ante, viverra nec sem ac, interdum sollicitudin nisl.'),
47 | Div('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Ut ut consequat neque, vel luctus elit. Nunc elementum sapien nunc, vel efficitur urna malesuada nec. Fusce vulputate ornare congue. Proin vulputate lacus lorem, vitae dignissim massa luctus eget. Nam ante libero, ornare eu enim eu, vulputate suscipit nulla. Donec consectetur, dui vel malesuada ullamcorper, metus sem dignissim nunc, et varius nisl est sit amet nisl. Quisque placerat feugiat sapien, id vulputate turpis dignissim id. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Donec mauris ante, viverra nec sem ac, interdum sollicitudin nisl.'),
48 | cls="space-y-6 container-section"
49 | ),
50 | footer(links={'Follow me on Twitter': 'https://x.com/dgwyer'}),
51 | )
52 |
53 | ### Login Routes ###
54 |
55 | @rt("/login")
56 | def get(sess):
57 | user_info, links = header_html(sess)
58 |
59 | frm = Form(
60 | H2('Login', cls='mb-2'),
61 | #Input(id='name', type="text", placeholder='Name'),
62 | Input(id='email', type="email", placeholder='Email'),
63 | Input(id='pwd', type='password', placeholder='Password'),
64 | Button(
65 | 'Login',
66 | cls='btn'
67 | ),
68 | cls='mt-10 flex flex-col gap-x-2 items-center justify-center',
69 | action='/login',
70 | method='post'
71 | )
72 |
73 | return (
74 | header(links=links),
75 | Div(
76 | frm,
77 | cls="container-section"
78 | ),
79 | footer(links={'Follow me on Twitter': 'https://x.com/dgwyer'}),
80 | ),
81 |
82 | @rt("/login")
83 | def post(login:Login, sess):
84 | pprint(f"users: {dir(users)}")
85 | print(f"login: {login.email}, {login.name}, {login.pwd}")
86 |
87 | for row in db.q("select * from users"):
88 | print(row)
89 |
90 | # Todo
91 | # - If email no found then show user not found error. What error does WP show? Replace line 99
92 | # - Show signup link and need to add this route to signup a new user.
93 | # - This page should not allow signups if email already exists.
94 | # - Make the login form stak as in WP.
95 | # - Implement magic link login
96 | # - Check if email is valid
97 | # - Add some more colors via a CSS property and use this for button color too etc.
98 | # - Add a Twitter logo in footer.
99 | # - Start to use md5 hashing for passwords. And 2FA?
100 |
101 | if not login.email or not login.pwd: return login_redir
102 | try: u = users[login.name]
103 | # If the primary key does not exist, the method raises a `NotFoundError`.
104 | # Here we use this to just generate a user -- in practice you'd probably to redirect to a signup page.
105 | except NotFoundError: u = users.insert(login)
106 | if not compare_digest(u.pwd.encode("utf-8"), login.pwd.encode("utf-8")): return login_redir
107 | # Because the session is signed, we can securely add information to it. It's stored in the browser cookies.
108 | # If you don't pass a secret signing key to `FastHTML`, it will auto-generate one and store it in a file `./sesskey`.
109 | sess['auth'] = u.id
110 | sess['user_name'] = u.name
111 | intended_url = sess.pop('intended_url', '/')
112 |
113 | return RedirectResponse(intended_url, status_code=303)
114 |
115 | ### Logout Routes ###
116 |
117 | @rt("/logout")
118 | def get(sess):
119 | del sess['auth']
120 | if 'user_name' in sess:
121 | del sess['user_name']
122 | return home_redir
123 |
124 | ### Todo Routes ###
125 |
126 | # By including the `auth` parameter, it gets passed the current username, for displaying in the title.
127 | @rt("/todos")
128 | def get(auth, sess):
129 | user_name = sess.get('user_name', 'User')
130 | new_inp = Input(id="new-title", name="title", type="text", placeholder="New Todo")
131 | add = Form(Group(new_inp, Button("Add", cls='btn')),
132 | hx_post="/todos", target_id='todo-list', hx_swap="afterbegin", cls="mb-8")
133 | items = Tbody(*todos(order_by='priority'),
134 | id='todo-list', cls='sortable divide-y divide-gray-200')
135 | user_info, links = header_html(sess)
136 |
137 | return (
138 | header(links=links),
139 | Div(
140 | add,
141 | H2(f"{user_name}'s Todo list", cls='mb-6'),
142 | Table(
143 | Thead(
144 | Tr(
145 | Th('Title', scope='col', cls='min-w-[180px] pr-3 py-3.5 text-left text-sm font-semibold text-gray-900'),
146 | Th('ToDo', scope='col', cls='min-w-[300px] max-w-[500px] pr-3 py-3.5 text-left text-sm font-semibold text-gray-900'),
147 | Th('Done', scope='col', cls='min-w-[100px] pr-3 py-3.5 text-left text-sm font-semibold text-gray-900'),
148 | Th('Date Added', scope='col', cls='min-w-[100px] pr-3 py-3.5 text-left text-sm font-semibold text-gray-900'),
149 | Th(
150 | Span('Edit', cls='sr-only'),
151 | scope='col',
152 | cls='relative py-3.5 pl-3 pr-4 sm:pr-0'
153 | )
154 | )
155 | ),
156 | items,
157 | cls='min-w-fit divide-y divide-gray-300'
158 | ),
159 | Div(id='current-todo'),
160 | cls="container-section"
161 | ),
162 | footer(links={'Follow me on Twitter': 'https://x.com/dgwyer'}),
163 | )
164 |
165 | # This route handler uses a path parameter `{id}` which is automatically parsed and passed as an int.
166 | @rt("/todos/{id}")
167 | def delete(id:int):
168 | todos.delete(id)
169 | return clr_details()
170 |
171 | @rt("/edit/{id}")
172 | def get(id:int):
173 | res = Div(
174 | H2('Edit Todo', cls='mb-2'),
175 | Form(
176 | Div(Input(id="title", type="text", cls='w-[250px]')),
177 | Div(Textarea(id="details", name="details", cls='w-[250px] h-[100px]')),
178 | Div(CheckboxX(id="done", label='Done')),
179 | Div(
180 | Div(
181 | Button("Save", cls='btn small'),
182 | Button("Cancel", hx_put="/todos/cancel", cls='btn small'),
183 | cls="flex justify-between gap-x-2"
184 | ),
185 | Button('Delete', hx_delete=f'/todos/{id}', hx_target=f'#todo-{id}', hx_swap="outerHTML", cls='text-red-700 hover:text-red-500 small'),
186 | cls="flex justify-between space-x-4 w-[250px]"
187 | ),
188 | Hidden(id="id"),
189 | hx_put="/todos",
190 | hx_swap="outerHTML",
191 | hx_target=f'#todo-{id}',
192 | cls="space-y-3"
193 | ),
194 | id="edit",
195 | cls="mt-9"
196 | )
197 | # `fill_form` populates the form with existing todo data, and returns the result.
198 | # Indexing into a table (`todos`) queries by primary key, which is `id` here. It also includes
199 | # `xtra`, so this will only return the id if it belongs to the current user.
200 | return fill_form(res, todos[id])
201 |
202 | @rt("/todos/{id}")
203 | def get(id:int):
204 | todo = todos[id]
205 | return Div(
206 | H2(todo.title),
207 | Div(todo.details, cls="py-4"),
208 | Button('Delete', hx_delete=f'/todos/{todo.id}', target_id=f'todo-{todo.id}', hx_swap="outerHTML", cls='btn'),
209 | cls="mt-9"
210 | )
211 |
212 | @rt("/todos/cancel")
213 | def put():
214 | return clr_details()
215 |
216 | @rt("/todos")
217 | def put(todo: Todo):
218 | todo_title = todo.title.strip()
219 | if todo_title == '' or todo_title is None:
220 | return clr_details()
221 |
222 | return todos.update(todo), clr_details()
223 |
224 | @rt("/todos")
225 | def post(todo:Todo, auth, sess):
226 | print(f"auth: {auth}, sess: {sess}")
227 | todo_title = todo.title.strip()
228 | if todo_title == '' or todo_title is None:
229 | return None
230 |
231 | # `hx_swap_oob='true'` tells HTMX to perform an out-of-band swap, updating this element wherever it appears.
232 | # This is used to clear the input field after adding the new todo.
233 | new_inp = Input(id="new-title", name="title", type="text", placeholder="New Todo", hx_swap_oob='true')
234 | # `insert` returns the inserted todo, which is appended to the start of the list, because we used
235 | # `hx_swap='afterbegin'` when creating the todo list form.
236 | todo.date = str(int(datetime.now().timestamp()))
237 | todo.user_id = auth
238 | print(todo)
239 | return todos.insert(todo), new_inp
240 |
241 | ### Process Static Files ###
242 |
243 | @rt("/{fname:path}.{ext:static}")
244 | def get(fname:str, ext:str): return FileResponse(f'{fname}.{ext}')
245 |
246 | # Start the Python web server, SQLite editor, Jupyter Lab server, and Tailwind CSS compiler.
247 | def serve_dev(db=False, jupyter=False, tw=False, db_path='data/main.db', sqlite_port=8035, jupyter_port=8036, tw_src='./src/app.css', tw_dist='./public/app.css'):
248 | print("Starting servers...")
249 | if db:
250 | print("Starting SQLite...")
251 | sqlite_process = subprocess.Popen(
252 | ['sqlite_web', db_path, '--port', str(sqlite_port), '--no-browser'],
253 | stdout=subprocess.DEVNULL,
254 | stderr=subprocess.DEVNULL
255 | )
256 | print(f'SQLite: http://localhost:{sqlite_port}')
257 |
258 | if jupyter:
259 | print("Starting Jupyter...")
260 | jupyter_process = subprocess.Popen(
261 | ['jupyter', 'lab', '--port', str(jupyter_port), '--no-browser', '--NotebookApp.token=', '--NotebookApp.password='],
262 | stdout=subprocess.PIPE,
263 | stderr=subprocess.PIPE,
264 | text=True
265 | )
266 |
267 | # Extract and print the Jupyter Lab URL
268 | for line in jupyter_process.stderr:
269 | if 'http://' in line:
270 | match = re.search(r'(http://localhost:\d+/lab)', line)
271 | if match:
272 | print(f'Jupyter Lab: {match.group(1)}')
273 | break
274 |
275 | if tw:
276 | print("Starting Tailwind...")
277 | tailwind_process = subprocess.Popen(
278 | ['tailwindcss', '-i', tw_src, '-o', tw_dist, '--watch'],
279 | stdout=subprocess.DEVNULL,
280 | stderr=subprocess.DEVNULL
281 | )
282 |
283 | try:
284 | print("Starting FastAPI...")
285 | serve(reload_includes=["*.css"])
286 | finally:
287 | if db:
288 | sqlite_process.terminate()
289 | if jupyter:
290 | jupyter_process.terminate()
291 | if tw:
292 | tailwind_process.terminate()
293 |
294 | serve_dev(db=True, tw=True, jupyter=True)
295 | #serve()
296 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/migrate.py:
--------------------------------------------------------------------------------
1 | from fastlite import database
2 | import os
3 |
4 | # Before running the migration script:
5 | # - Set app into maintenance mode?
6 | # - Backup the database
7 | # - Run during low traffic periods if possible
8 |
9 | # use 'unset MAINTENANCE_MODE' to disable maintenance mode via the terminal, and 'export MAINTENANCE_MODE=true' to enable it
10 |
11 | """
12 | Try this workflow to set maintenance mode:
13 |
14 | # config.py
15 | import os
16 |
17 | MAINTENANCE_MODE = os.getenv('MAINTENANCE_MODE', '0') == '1'
18 |
19 | # app.py
20 | from config import MAINTENANCE_MODE
21 |
22 | @app.before_request
23 | def check_maintenance():
24 | if MAINTENANCE_MODE and not request.path.startswith('/maintenance'):
25 | return render_template('maintenance.html'), 503
26 |
27 | # migrate.py
28 | os.environ['MAINTENANCE_MODE'] = '1'
29 | try:
30 | migrate()
31 | finally:
32 | os.environ['MAINTENANCE_MODE'] = '0'
33 | """
34 |
35 | MAINTENANCE_MODE = False
36 |
37 | def set_mm():
38 | env_key = 'MAINTENANCE_MODE'
39 | mm = 'false'
40 | try:
41 | print(f"Test environ key: {env_key}")
42 | mm = os.environ[env_key]
43 | except KeyError:
44 | print(f"{env_key} not found in environment variables")
45 | if mm == 'true':
46 | print(f"Maintenance mode is enabled: {mm}")
47 | else:
48 | print(f"Maintenance mode is disabled: {mm}")
49 |
50 | def migrate():
51 | db = database('data/main.db')
52 |
53 | print("Migrating users table: adding email column...")
54 |
55 | try:
56 | # Start transaction
57 | db.execute("BEGIN TRANSACTION;")
58 |
59 | # 1. Create new table with desired schema
60 | db.execute("""
61 | CREATE TABLE users_new (
62 | id INTEGER PRIMARY KEY,
63 | name TEXT,
64 | pwd TEXT,
65 | email TEXT UNIQUE DEFAULT NULL
66 | );
67 | """)
68 |
69 | # 2. Copy data from old table to new table
70 | print("Copying data to new table...")
71 | db.execute("""
72 | INSERT INTO users_new (id, name, pwd)
73 | SELECT id, name, pwd FROM users;
74 | """)
75 |
76 | # Verify data was copied
77 | count_old = db.q("SELECT COUNT(*) as count FROM users")[0]['count']
78 | count_new = db.q("SELECT COUNT(*) as count FROM users_new")[0]['count']
79 | print(f"Copied {count_new} of {count_old} records")
80 |
81 | if count_old != count_new:
82 | raise Exception("Data copy mismatch!")
83 |
84 | # 3. Drop old table
85 | print("Dropping old table...")
86 | db.execute("DROP TABLE users;")
87 |
88 | # 4. Rename new table to original name
89 | print("Renaming new table...")
90 | db.execute("ALTER TABLE users_new RENAME TO users;")
91 |
92 | # Commit transaction
93 | db.execute("COMMIT;")
94 | print("Migration complete")
95 |
96 | except Exception as e:
97 | # If anything goes wrong, rollback
98 | db.execute("ROLLBACK;")
99 | print(f"Migration failed: {str(e)}")
100 | raise
101 |
102 | if __name__ == "__main__":
103 | migrate()
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "04-sqlite-boilerplate",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "tailwind.config.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "devDependencies": {
13 | "@tailwindcss/forms": "^0.5.9"
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/public/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 |
3 | /* ! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com */
4 |
5 | /*
6 | 1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4)
7 | 2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116)
8 | */
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | /* 1 */
15 | border-width: 0;
16 | /* 2 */
17 | border-style: solid;
18 | /* 2 */
19 | border-color: #e5e7eb;
20 | /* 2 */
21 | }
22 |
23 | ::before,
24 | ::after {
25 | --tw-content: '';
26 | }
27 |
28 | /*
29 | 1. Use a consistent sensible line-height in all browsers.
30 | 2. Prevent adjustments of font size after orientation changes in iOS.
31 | 3. Use a more readable tab size.
32 | 4. Use the user's configured `sans` font-family by default.
33 | 5. Use the user's configured `sans` font-feature-settings by default.
34 | */
35 |
36 | html {
37 | line-height: 1.5;
38 | /* 1 */
39 | -webkit-text-size-adjust: 100%;
40 | /* 2 */
41 | -moz-tab-size: 4;
42 | /* 3 */
43 | -o-tab-size: 4;
44 | tab-size: 4;
45 | /* 3 */
46 | font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
47 | /* 4 */
48 | font-feature-settings: normal;
49 | /* 5 */
50 | }
51 |
52 | /*
53 | 1. Remove the margin in all browsers.
54 | 2. Inherit line-height from `html` so users can set them as a class directly on the `html` element.
55 | */
56 |
57 | body {
58 | margin: 0;
59 | /* 1 */
60 | line-height: inherit;
61 | /* 2 */
62 | }
63 |
64 | /*
65 | 1. Add the correct height in Firefox.
66 | 2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655)
67 | 3. Ensure horizontal rules are visible by default.
68 | */
69 |
70 | hr {
71 | height: 0;
72 | /* 1 */
73 | color: inherit;
74 | /* 2 */
75 | border-top-width: 1px;
76 | /* 3 */
77 | }
78 |
79 | /*
80 | Add the correct text decoration in Chrome, Edge, and Safari.
81 | */
82 |
83 | abbr:where([title]) {
84 | -webkit-text-decoration: underline dotted;
85 | text-decoration: underline dotted;
86 | }
87 |
88 | /*
89 | Remove the default font size and weight for headings.
90 | */
91 |
92 | h1,
93 | h2,
94 | h3,
95 | h4,
96 | h5,
97 | h6 {
98 | font-size: inherit;
99 | font-weight: inherit;
100 | }
101 |
102 | /*
103 | Reset links to optimize for opt-in styling instead of opt-out.
104 | */
105 |
106 | a {
107 | color: inherit;
108 | text-decoration: inherit;
109 | }
110 |
111 | /*
112 | Add the correct font weight in Edge and Safari.
113 | */
114 |
115 | b,
116 | strong {
117 | font-weight: bolder;
118 | }
119 |
120 | /*
121 | 1. Use the user's configured `mono` font family by default.
122 | 2. Correct the odd `em` font sizing in all browsers.
123 | */
124 |
125 | code,
126 | kbd,
127 | samp,
128 | pre {
129 | font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
130 | /* 1 */
131 | font-size: 1em;
132 | /* 2 */
133 | }
134 |
135 | /*
136 | Add the correct font size in all browsers.
137 | */
138 |
139 | small {
140 | font-size: 80%;
141 | }
142 |
143 | /*
144 | Prevent `sub` and `sup` elements from affecting the line height in all browsers.
145 | */
146 |
147 | sub,
148 | sup {
149 | font-size: 75%;
150 | line-height: 0;
151 | position: relative;
152 | vertical-align: baseline;
153 | }
154 |
155 | sub {
156 | bottom: -0.25em;
157 | }
158 |
159 | sup {
160 | top: -0.5em;
161 | }
162 |
163 | /*
164 | 1. Remove text indentation from table contents in Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=999088, https://bugs.webkit.org/show_bug.cgi?id=201297)
165 | 2. Correct table border color inheritance in all Chrome and Safari. (https://bugs.chromium.org/p/chromium/issues/detail?id=935729, https://bugs.webkit.org/show_bug.cgi?id=195016)
166 | 3. Remove gaps between table borders by default.
167 | */
168 |
169 | table {
170 | text-indent: 0;
171 | /* 1 */
172 | border-color: inherit;
173 | /* 2 */
174 | border-collapse: collapse;
175 | /* 3 */
176 | }
177 |
178 | /*
179 | 1. Change the font styles in all browsers.
180 | 2. Remove the margin in Firefox and Safari.
181 | 3. Remove default padding in all browsers.
182 | */
183 |
184 | button,
185 | input,
186 | optgroup,
187 | select,
188 | textarea {
189 | font-family: inherit;
190 | /* 1 */
191 | font-size: 100%;
192 | /* 1 */
193 | font-weight: inherit;
194 | /* 1 */
195 | line-height: inherit;
196 | /* 1 */
197 | color: inherit;
198 | /* 1 */
199 | margin: 0;
200 | /* 2 */
201 | padding: 0;
202 | /* 3 */
203 | }
204 |
205 | /*
206 | Remove the inheritance of text transform in Edge and Firefox.
207 | */
208 |
209 | button,
210 | select {
211 | text-transform: none;
212 | }
213 |
214 | /*
215 | 1. Correct the inability to style clickable types in iOS and Safari.
216 | 2. Remove default button styles.
217 | */
218 |
219 | button,
220 | [type='button'],
221 | [type='reset'],
222 | [type='submit'] {
223 | -webkit-appearance: button;
224 | /* 1 */
225 | background-color: transparent;
226 | /* 2 */
227 | background-image: none;
228 | /* 2 */
229 | }
230 |
231 | /*
232 | Use the modern Firefox focus style for all focusable elements.
233 | */
234 |
235 | :-moz-focusring {
236 | outline: auto;
237 | }
238 |
239 | /*
240 | Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737)
241 | */
242 |
243 | :-moz-ui-invalid {
244 | box-shadow: none;
245 | }
246 |
247 | /*
248 | Add the correct vertical alignment in Chrome and Firefox.
249 | */
250 |
251 | progress {
252 | vertical-align: baseline;
253 | }
254 |
255 | /*
256 | Correct the cursor style of increment and decrement buttons in Safari.
257 | */
258 |
259 | ::-webkit-inner-spin-button,
260 | ::-webkit-outer-spin-button {
261 | height: auto;
262 | }
263 |
264 | /*
265 | 1. Correct the odd appearance in Chrome and Safari.
266 | 2. Correct the outline style in Safari.
267 | */
268 |
269 | [type='search'] {
270 | -webkit-appearance: textfield;
271 | /* 1 */
272 | outline-offset: -2px;
273 | /* 2 */
274 | }
275 |
276 | /*
277 | Remove the inner padding in Chrome and Safari on macOS.
278 | */
279 |
280 | ::-webkit-search-decoration {
281 | -webkit-appearance: none;
282 | }
283 |
284 | /*
285 | 1. Correct the inability to style clickable types in iOS and Safari.
286 | 2. Change font properties to `inherit` in Safari.
287 | */
288 |
289 | ::-webkit-file-upload-button {
290 | -webkit-appearance: button;
291 | /* 1 */
292 | font: inherit;
293 | /* 2 */
294 | }
295 |
296 | /*
297 | Add the correct display in Chrome and Safari.
298 | */
299 |
300 | summary {
301 | display: list-item;
302 | }
303 |
304 | /*
305 | Removes the default spacing and border for appropriate elements.
306 | */
307 |
308 | blockquote,
309 | dl,
310 | dd,
311 | h1,
312 | h2,
313 | h3,
314 | h4,
315 | h5,
316 | h6,
317 | hr,
318 | figure,
319 | p,
320 | pre {
321 | margin: 0;
322 | }
323 |
324 | fieldset {
325 | margin: 0;
326 | padding: 0;
327 | }
328 |
329 | legend {
330 | padding: 0;
331 | }
332 |
333 | ol,
334 | ul,
335 | menu {
336 | list-style: none;
337 | margin: 0;
338 | padding: 0;
339 | }
340 |
341 | /*
342 | Prevent resizing textareas horizontally by default.
343 | */
344 |
345 | textarea {
346 | resize: vertical;
347 | }
348 |
349 | /*
350 | 1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300)
351 | 2. Set the default placeholder color to the user's configured gray 400 color.
352 | */
353 |
354 | input::-moz-placeholder, textarea::-moz-placeholder {
355 | opacity: 1;
356 | /* 1 */
357 | color: #9ca3af;
358 | /* 2 */
359 | }
360 |
361 | input::placeholder,
362 | textarea::placeholder {
363 | opacity: 1;
364 | /* 1 */
365 | color: #9ca3af;
366 | /* 2 */
367 | }
368 |
369 | /*
370 | Set the default cursor for buttons.
371 | */
372 |
373 | button,
374 | [role="button"] {
375 | cursor: pointer;
376 | }
377 |
378 | /*
379 | Make sure disabled buttons don't get the pointer cursor.
380 | */
381 |
382 | :disabled {
383 | cursor: default;
384 | }
385 |
386 | /*
387 | 1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14)
388 | 2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210)
389 | This can trigger a poorly considered lint error in some tools but is included by design.
390 | */
391 |
392 | img,
393 | svg,
394 | video,
395 | canvas,
396 | audio,
397 | iframe,
398 | embed,
399 | object {
400 | display: block;
401 | /* 1 */
402 | vertical-align: middle;
403 | /* 2 */
404 | }
405 |
406 | /*
407 | Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14)
408 | */
409 |
410 | img,
411 | video {
412 | max-width: 100%;
413 | height: auto;
414 | }
415 |
416 | /* Make elements with the HTML hidden attribute stay hidden by default */
417 |
418 | [hidden] {
419 | display: none;
420 | }
421 |
422 | [type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
423 | -webkit-appearance: none;
424 | -moz-appearance: none;
425 | appearance: none;
426 | background-color: #fff;
427 | border-color: #6b7280;
428 | border-width: 1px;
429 | border-radius: 0px;
430 | padding-top: 0.5rem;
431 | padding-right: 0.75rem;
432 | padding-bottom: 0.5rem;
433 | padding-left: 0.75rem;
434 | font-size: 1rem;
435 | line-height: 1.5rem;
436 | --tw-shadow: 0 0 #0000;
437 | }
438 |
439 | [type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
440 | outline: 2px solid transparent;
441 | outline-offset: 2px;
442 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
443 | --tw-ring-offset-width: 0px;
444 | --tw-ring-offset-color: #fff;
445 | --tw-ring-color: #2563eb;
446 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
447 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
448 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
449 | border-color: #2563eb;
450 | }
451 |
452 | input::-moz-placeholder, textarea::-moz-placeholder {
453 | color: #6b7280;
454 | opacity: 1;
455 | }
456 |
457 | input::placeholder,textarea::placeholder {
458 | color: #6b7280;
459 | opacity: 1;
460 | }
461 |
462 | ::-webkit-datetime-edit-fields-wrapper {
463 | padding: 0;
464 | }
465 |
466 | ::-webkit-date-and-time-value {
467 | min-height: 1.5em;
468 | }
469 |
470 | ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
471 | padding-top: 0;
472 | padding-bottom: 0;
473 | }
474 |
475 | select {
476 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
477 | background-position: right 0.5rem center;
478 | background-repeat: no-repeat;
479 | background-size: 1.5em 1.5em;
480 | padding-right: 2.5rem;
481 | -webkit-print-color-adjust: exact;
482 | print-color-adjust: exact;
483 | }
484 |
485 | [multiple] {
486 | background-image: initial;
487 | background-position: initial;
488 | background-repeat: unset;
489 | background-size: initial;
490 | padding-right: 0.75rem;
491 | -webkit-print-color-adjust: unset;
492 | print-color-adjust: unset;
493 | }
494 |
495 | [type='checkbox'],[type='radio'] {
496 | -webkit-appearance: none;
497 | -moz-appearance: none;
498 | appearance: none;
499 | padding: 0;
500 | -webkit-print-color-adjust: exact;
501 | print-color-adjust: exact;
502 | display: inline-block;
503 | vertical-align: middle;
504 | background-origin: border-box;
505 | -webkit-user-select: none;
506 | -moz-user-select: none;
507 | user-select: none;
508 | flex-shrink: 0;
509 | height: 1rem;
510 | width: 1rem;
511 | color: #2563eb;
512 | background-color: #fff;
513 | border-color: #6b7280;
514 | border-width: 1px;
515 | --tw-shadow: 0 0 #0000;
516 | }
517 |
518 | [type='checkbox'] {
519 | border-radius: 0px;
520 | }
521 |
522 | [type='radio'] {
523 | border-radius: 100%;
524 | }
525 |
526 | [type='checkbox']:focus,[type='radio']:focus {
527 | outline: 2px solid transparent;
528 | outline-offset: 2px;
529 | --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
530 | --tw-ring-offset-width: 2px;
531 | --tw-ring-offset-color: #fff;
532 | --tw-ring-color: #2563eb;
533 | --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
534 | --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);
535 | box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
536 | }
537 |
538 | [type='checkbox']:checked,[type='radio']:checked {
539 | border-color: transparent;
540 | background-color: currentColor;
541 | background-size: 100% 100%;
542 | background-position: center;
543 | background-repeat: no-repeat;
544 | }
545 |
546 | [type='checkbox']:checked {
547 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e");
548 | }
549 |
550 | [type='radio']:checked {
551 | background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3ccircle cx='8' cy='8' r='3'/%3e%3c/svg%3e");
552 | }
553 |
554 | [type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus {
555 | border-color: transparent;
556 | background-color: currentColor;
557 | }
558 |
559 | [type='checkbox']:indeterminate {
560 | background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 16 16'%3e%3cpath stroke='white' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M4 8h8'/%3e%3c/svg%3e");
561 | border-color: transparent;
562 | background-color: currentColor;
563 | background-size: 100% 100%;
564 | background-position: center;
565 | background-repeat: no-repeat;
566 | }
567 |
568 | [type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus {
569 | border-color: transparent;
570 | background-color: currentColor;
571 | }
572 |
573 | [type='file'] {
574 | background: unset;
575 | border-color: inherit;
576 | border-width: 0;
577 | border-radius: 0;
578 | padding: 0;
579 | font-size: unset;
580 | line-height: inherit;
581 | }
582 |
583 | [type='file']:focus {
584 | outline: 1px solid ButtonText;
585 | outline: 1px auto -webkit-focus-ring-color;
586 | }
587 |
588 | *, ::before, ::after {
589 | --tw-border-spacing-x: 0;
590 | --tw-border-spacing-y: 0;
591 | --tw-translate-x: 0;
592 | --tw-translate-y: 0;
593 | --tw-rotate: 0;
594 | --tw-skew-x: 0;
595 | --tw-skew-y: 0;
596 | --tw-scale-x: 1;
597 | --tw-scale-y: 1;
598 | --tw-pan-x: ;
599 | --tw-pan-y: ;
600 | --tw-pinch-zoom: ;
601 | --tw-scroll-snap-strictness: proximity;
602 | --tw-ordinal: ;
603 | --tw-slashed-zero: ;
604 | --tw-numeric-figure: ;
605 | --tw-numeric-spacing: ;
606 | --tw-numeric-fraction: ;
607 | --tw-ring-inset: ;
608 | --tw-ring-offset-width: 0px;
609 | --tw-ring-offset-color: #fff;
610 | --tw-ring-color: rgb(59 130 246 / 0.5);
611 | --tw-ring-offset-shadow: 0 0 #0000;
612 | --tw-ring-shadow: 0 0 #0000;
613 | --tw-shadow: 0 0 #0000;
614 | --tw-shadow-colored: 0 0 #0000;
615 | --tw-blur: ;
616 | --tw-brightness: ;
617 | --tw-contrast: ;
618 | --tw-grayscale: ;
619 | --tw-hue-rotate: ;
620 | --tw-invert: ;
621 | --tw-saturate: ;
622 | --tw-sepia: ;
623 | --tw-drop-shadow: ;
624 | --tw-backdrop-blur: ;
625 | --tw-backdrop-brightness: ;
626 | --tw-backdrop-contrast: ;
627 | --tw-backdrop-grayscale: ;
628 | --tw-backdrop-hue-rotate: ;
629 | --tw-backdrop-invert: ;
630 | --tw-backdrop-opacity: ;
631 | --tw-backdrop-saturate: ;
632 | --tw-backdrop-sepia: ;
633 | }
634 |
635 | ::backdrop {
636 | --tw-border-spacing-x: 0;
637 | --tw-border-spacing-y: 0;
638 | --tw-translate-x: 0;
639 | --tw-translate-y: 0;
640 | --tw-rotate: 0;
641 | --tw-skew-x: 0;
642 | --tw-skew-y: 0;
643 | --tw-scale-x: 1;
644 | --tw-scale-y: 1;
645 | --tw-pan-x: ;
646 | --tw-pan-y: ;
647 | --tw-pinch-zoom: ;
648 | --tw-scroll-snap-strictness: proximity;
649 | --tw-ordinal: ;
650 | --tw-slashed-zero: ;
651 | --tw-numeric-figure: ;
652 | --tw-numeric-spacing: ;
653 | --tw-numeric-fraction: ;
654 | --tw-ring-inset: ;
655 | --tw-ring-offset-width: 0px;
656 | --tw-ring-offset-color: #fff;
657 | --tw-ring-color: rgb(59 130 246 / 0.5);
658 | --tw-ring-offset-shadow: 0 0 #0000;
659 | --tw-ring-shadow: 0 0 #0000;
660 | --tw-shadow: 0 0 #0000;
661 | --tw-shadow-colored: 0 0 #0000;
662 | --tw-blur: ;
663 | --tw-brightness: ;
664 | --tw-contrast: ;
665 | --tw-grayscale: ;
666 | --tw-hue-rotate: ;
667 | --tw-invert: ;
668 | --tw-saturate: ;
669 | --tw-sepia: ;
670 | --tw-drop-shadow: ;
671 | --tw-backdrop-blur: ;
672 | --tw-backdrop-brightness: ;
673 | --tw-backdrop-contrast: ;
674 | --tw-backdrop-grayscale: ;
675 | --tw-backdrop-hue-rotate: ;
676 | --tw-backdrop-invert: ;
677 | --tw-backdrop-opacity: ;
678 | --tw-backdrop-saturate: ;
679 | --tw-backdrop-sepia: ;
680 | }
681 |
682 | .container-section {
683 | margin-left: auto;
684 | margin-right: auto;
685 | min-width: 515px;
686 | max-width: 1200px;
687 | padding: 2rem;
688 | }
689 |
690 | h2 {
691 | margin-bottom: 0.5rem;
692 | font-size: 1.5rem;
693 | line-height: 2rem;
694 | font-weight: 700;
695 | }
696 |
697 | .btn {
698 | display: inline-flex;
699 | align-items: center;
700 | -moz-column-gap: 0.5rem;
701 | column-gap: 0.5rem;
702 | border-radius: 0.125rem;
703 | --tw-bg-opacity: 1;
704 | background-color: rgb(79 70 229 / var(--tw-bg-opacity));
705 | padding-left: 0.875rem;
706 | padding-right: 0.875rem;
707 | padding-top: 0.625rem;
708 | padding-bottom: 0.625rem;
709 | font-size: 0.875rem;
710 | line-height: 1.25rem;
711 | font-weight: 600;
712 | --tw-text-opacity: 1;
713 | color: rgb(255 255 255 / var(--tw-text-opacity));
714 | --tw-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05);
715 | --tw-shadow-colored: 0 1px 2px 0 var(--tw-shadow-color);
716 | box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
717 | }
718 |
719 | .btn:hover {
720 | --tw-bg-opacity: 1;
721 | background-color: rgb(99 102 241 / var(--tw-bg-opacity));
722 | }
723 |
724 | .btn:focus-visible {
725 | outline-style: solid;
726 | outline-width: 2px;
727 | outline-offset: 2px;
728 | outline-color: #4f46e5;
729 | }
730 |
731 | .small {
732 | padding-left: 0.75rem;
733 | padding-right: 0.75rem;
734 | padding-top: 0.5rem;
735 | padding-bottom: 0.5rem;
736 | font-size: 0.75rem;
737 | line-height: 1rem;
738 | }
739 |
740 | .sr-only {
741 | position: absolute;
742 | width: 1px;
743 | height: 1px;
744 | padding: 0;
745 | margin: -1px;
746 | overflow: hidden;
747 | clip: rect(0, 0, 0, 0);
748 | white-space: nowrap;
749 | border-width: 0;
750 | }
751 |
752 | .relative {
753 | position: relative;
754 | }
755 |
756 | .m-0 {
757 | margin: 0px;
758 | }
759 |
760 | .mb-2 {
761 | margin-bottom: 0.5rem;
762 | }
763 |
764 | .mt-10 {
765 | margin-top: 2.5rem;
766 | }
767 |
768 | .mb-8 {
769 | margin-bottom: 2rem;
770 | }
771 |
772 | .mb-6 {
773 | margin-bottom: 1.5rem;
774 | }
775 |
776 | .mt-9 {
777 | margin-top: 2.25rem;
778 | }
779 |
780 | .mt-4 {
781 | margin-top: 1rem;
782 | }
783 |
784 | .mt-6 {
785 | margin-top: 1.5rem;
786 | }
787 |
788 | .mr-auto {
789 | margin-right: auto;
790 | }
791 |
792 | .ml-auto {
793 | margin-left: auto;
794 | }
795 |
796 | .-ml-0\.5 {
797 | margin-left: -0.125rem;
798 | }
799 |
800 | .-ml-0 {
801 | margin-left: -0px;
802 | }
803 |
804 | .inline {
805 | display: inline;
806 | }
807 |
808 | .flex {
809 | display: flex;
810 | }
811 |
812 | .inline-flex {
813 | display: inline-flex;
814 | }
815 |
816 | .table {
817 | display: table;
818 | }
819 |
820 | .grid {
821 | display: grid;
822 | }
823 |
824 | .h-\[100px\] {
825 | height: 100px;
826 | }
827 |
828 | .h-\[24px\] {
829 | height: 24px;
830 | }
831 |
832 | .h-5 {
833 | height: 1.25rem;
834 | }
835 |
836 | .min-h-full {
837 | min-height: 100%;
838 | }
839 |
840 | .w-\[250px\] {
841 | width: 250px;
842 | }
843 |
844 | .w-\[125px\] {
845 | width: 125px;
846 | }
847 |
848 | .w-5 {
849 | width: 1.25rem;
850 | }
851 |
852 | .min-w-\[180px\] {
853 | min-width: 180px;
854 | }
855 |
856 | .min-w-\[300px\] {
857 | min-width: 300px;
858 | }
859 |
860 | .min-w-\[100px\] {
861 | min-width: 100px;
862 | }
863 |
864 | .min-w-fit {
865 | min-width: -moz-fit-content;
866 | min-width: fit-content;
867 | }
868 |
869 | .max-w-\[500px\] {
870 | max-width: 500px;
871 | }
872 |
873 | .list-none {
874 | list-style-type: none;
875 | }
876 |
877 | .flex-row {
878 | flex-direction: row;
879 | }
880 |
881 | .flex-col {
882 | flex-direction: column;
883 | }
884 |
885 | .flex-wrap {
886 | flex-wrap: wrap;
887 | }
888 |
889 | .place-items-center {
890 | place-items: center;
891 | }
892 |
893 | .items-end {
894 | align-items: flex-end;
895 | }
896 |
897 | .items-center {
898 | align-items: center;
899 | }
900 |
901 | .justify-center {
902 | justify-content: center;
903 | }
904 |
905 | .justify-between {
906 | justify-content: space-between;
907 | }
908 |
909 | .gap-x-2 {
910 | -moz-column-gap: 0.5rem;
911 | column-gap: 0.5rem;
912 | }
913 |
914 | .gap-x-6 {
915 | -moz-column-gap: 1.5rem;
916 | column-gap: 1.5rem;
917 | }
918 |
919 | .gap-x-8 {
920 | -moz-column-gap: 2rem;
921 | column-gap: 2rem;
922 | }
923 |
924 | .gap-y-4 {
925 | row-gap: 1rem;
926 | }
927 |
928 | .space-y-6 > :not([hidden]) ~ :not([hidden]) {
929 | --tw-space-y-reverse: 0;
930 | margin-top: calc(1.5rem * calc(1 - var(--tw-space-y-reverse)));
931 | margin-bottom: calc(1.5rem * var(--tw-space-y-reverse));
932 | }
933 |
934 | .space-x-4 > :not([hidden]) ~ :not([hidden]) {
935 | --tw-space-x-reverse: 0;
936 | margin-right: calc(1rem * var(--tw-space-x-reverse));
937 | margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse)));
938 | }
939 |
940 | .space-y-3 > :not([hidden]) ~ :not([hidden]) {
941 | --tw-space-y-reverse: 0;
942 | margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
943 | margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
944 | }
945 |
946 | .divide-y > :not([hidden]) ~ :not([hidden]) {
947 | --tw-divide-y-reverse: 0;
948 | border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse)));
949 | border-bottom-width: calc(1px * var(--tw-divide-y-reverse));
950 | }
951 |
952 | .divide-gray-200 > :not([hidden]) ~ :not([hidden]) {
953 | --tw-divide-opacity: 1;
954 | border-color: rgb(229 231 235 / var(--tw-divide-opacity));
955 | }
956 |
957 | .divide-gray-300 > :not([hidden]) ~ :not([hidden]) {
958 | --tw-divide-opacity: 1;
959 | border-color: rgb(209 213 219 / var(--tw-divide-opacity));
960 | }
961 |
962 | .bg-white {
963 | --tw-bg-opacity: 1;
964 | background-color: rgb(255 255 255 / var(--tw-bg-opacity));
965 | }
966 |
967 | .p-0 {
968 | padding: 0px;
969 | }
970 |
971 | .py-4 {
972 | padding-top: 1rem;
973 | padding-bottom: 1rem;
974 | }
975 |
976 | .px-3 {
977 | padding-left: 0.75rem;
978 | padding-right: 0.75rem;
979 | }
980 |
981 | .py-3\.5 {
982 | padding-top: 0.875rem;
983 | padding-bottom: 0.875rem;
984 | }
985 |
986 | .py-3 {
987 | padding-top: 0.75rem;
988 | padding-bottom: 0.75rem;
989 | }
990 |
991 | .px-3\.5 {
992 | padding-left: 0.875rem;
993 | padding-right: 0.875rem;
994 | }
995 |
996 | .py-2\.5 {
997 | padding-top: 0.625rem;
998 | padding-bottom: 0.625rem;
999 | }
1000 |
1001 | .py-2 {
1002 | padding-top: 0.5rem;
1003 | padding-bottom: 0.5rem;
1004 | }
1005 |
1006 | .px-6 {
1007 | padding-left: 1.5rem;
1008 | padding-right: 1.5rem;
1009 | }
1010 |
1011 | .py-24 {
1012 | padding-top: 6rem;
1013 | padding-bottom: 6rem;
1014 | }
1015 |
1016 | .pl-4 {
1017 | padding-left: 1rem;
1018 | }
1019 |
1020 | .pr-3 {
1021 | padding-right: 0.75rem;
1022 | }
1023 |
1024 | .pl-3 {
1025 | padding-left: 0.75rem;
1026 | }
1027 |
1028 | .pr-4 {
1029 | padding-right: 1rem;
1030 | }
1031 |
1032 | .text-left {
1033 | text-align: left;
1034 | }
1035 |
1036 | .text-center {
1037 | text-align: center;
1038 | }
1039 |
1040 | .text-right {
1041 | text-align: right;
1042 | }
1043 |
1044 | .text-sm {
1045 | font-size: 0.875rem;
1046 | line-height: 1.25rem;
1047 | }
1048 |
1049 | .text-lg {
1050 | font-size: 1.125rem;
1051 | line-height: 1.75rem;
1052 | }
1053 |
1054 | .text-3xl {
1055 | font-size: 1.875rem;
1056 | line-height: 2.25rem;
1057 | }
1058 |
1059 | .text-base {
1060 | font-size: 1rem;
1061 | line-height: 1.5rem;
1062 | }
1063 |
1064 | .font-medium {
1065 | font-weight: 500;
1066 | }
1067 |
1068 | .font-semibold {
1069 | font-weight: 600;
1070 | }
1071 |
1072 | .font-bold {
1073 | font-weight: 700;
1074 | }
1075 |
1076 | .leading-7 {
1077 | line-height: 1.75rem;
1078 | }
1079 |
1080 | .tracking-tight {
1081 | letter-spacing: -0.025em;
1082 | }
1083 |
1084 | .text-indigo-600 {
1085 | --tw-text-opacity: 1;
1086 | color: rgb(79 70 229 / var(--tw-text-opacity));
1087 | }
1088 |
1089 | .text-gray-900 {
1090 | --tw-text-opacity: 1;
1091 | color: rgb(17 24 39 / var(--tw-text-opacity));
1092 | }
1093 |
1094 | .text-gray-500 {
1095 | --tw-text-opacity: 1;
1096 | color: rgb(107 114 128 / var(--tw-text-opacity));
1097 | }
1098 |
1099 | .text-red-700 {
1100 | --tw-text-opacity: 1;
1101 | color: rgb(185 28 28 / var(--tw-text-opacity));
1102 | }
1103 |
1104 | .text-gray-600 {
1105 | --tw-text-opacity: 1;
1106 | color: rgb(75 85 99 / var(--tw-text-opacity));
1107 | }
1108 |
1109 | .hover\:text-indigo-900:hover {
1110 | --tw-text-opacity: 1;
1111 | color: rgb(49 46 129 / var(--tw-text-opacity));
1112 | }
1113 |
1114 | .hover\:text-red-500:hover {
1115 | --tw-text-opacity: 1;
1116 | color: rgb(239 68 68 / var(--tw-text-opacity));
1117 | }
1118 |
1119 | @media (min-width: 640px) {
1120 | .sm\:py-32 {
1121 | padding-top: 8rem;
1122 | padding-bottom: 8rem;
1123 | }
1124 |
1125 | .sm\:pl-0 {
1126 | padding-left: 0px;
1127 | }
1128 |
1129 | .sm\:pr-0 {
1130 | padding-right: 0px;
1131 | }
1132 |
1133 | .sm\:text-5xl {
1134 | font-size: 3rem;
1135 | line-height: 1;
1136 | }
1137 | }
1138 |
1139 | @media (min-width: 1024px) {
1140 | .lg\:px-8 {
1141 | padding-left: 2rem;
1142 | padding-right: 2rem;
1143 | }
1144 | }
1145 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/requirements.txt:
--------------------------------------------------------------------------------
1 | python-fasthtml
2 | uvicorn>=0.29
3 | python-multipart
4 | sqlite-utils
5 | huggingface-hub>=0.20.0
6 | fasthtml-hf
7 | jupyterlab
8 | sqlite-web
9 | graphviz
10 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/src/app.css:
--------------------------------------------------------------------------------
1 | /* app.css */
2 | @tailwind base;
3 | @tailwind components;
4 | @tailwind utilities;
5 |
6 | @layer components {
7 | .container-section {
8 | @apply max-w-[1200px] min-w-[515px] mx-auto p-8;
9 | }
10 |
11 | h2 {
12 | @apply text-2xl font-bold mb-2;
13 | }
14 |
15 | .btn {
16 | @apply inline-flex items-center gap-x-2 rounded-sm bg-indigo-600 px-3.5 py-2.5 text-sm font-semibold text-white shadow-sm hover:bg-indigo-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-indigo-600;
17 | }
18 |
19 | .small {
20 | @apply text-xs px-3 py-2;
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: ["**/*.py"],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [
8 | require('@tailwindcss/forms'),
9 | ],
10 | }
11 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/templates.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 |
3 | # Custom 404 response
4 | def _404(req, exc): return Title('404 - Page not found!'), Main(
5 | Div(
6 | P('404', cls='text-lg font-semibold text-indigo-600'),
7 | H1('Page not found', cls='mt-4 text-3xl font-bold tracking-tight text-gray-900 sm:text-5xl'),
8 | P('Sorry, we couldn’t find the page you’re looking for.', cls='mt-6 text-base leading-7 text-gray-600'),
9 | Div(
10 | Button(A(home_svg(), 'Home', href='/', cls='inline-flex items-center gap-x-2 px-3.5 py-2.5'), cls='btn p-0'),
11 | cls='mt-10 flex items-center justify-center gap-x-6'
12 | ),
13 | cls='text-center'
14 | ),
15 | cls='grid min-h-full place-items-center bg-white px-6 py-24 sm:py-32 lg:px-8'
16 | )
17 |
18 | def clr_details(): return Div(hx_swap_oob='innerHTML', id='current-todo')
19 |
20 | def header(logo='assets/logo.svg', logo_alt='Made with FastHTML', links={}): return Div(
21 | Nav(
22 | Ul(
23 | Li(
24 | A(
25 | Img(src=logo, alt=logo_alt, cls='w-[125px] h-[24px]'),
26 | href='/'
27 | ),
28 | cls="mr-auto"
29 | ),
30 | *[Li(A(link, href=href)) for link, href in links.items()],
31 | cls="m-0 p-0 list-none flex flex-row flex-wrap gap-x-8 gap-y-4 justify-between items-end font-medium"
32 | ),
33 | cls="container-section"
34 | ),
35 | )
36 |
37 | def footer(logo='assets/logo.svg', logo_alt='Made with FastHTML', links={}): return Div(
38 | Nav(
39 | Ul(
40 | *[Li(A(link, href=href)) for link, href in links.items()],
41 | Li(
42 | Span('Built with'),
43 | A(
44 | Img(src=logo, alt=logo_alt, cls='w-[125px] h-[24px] inline'),
45 | href='https://www.fastht.ml/'
46 | ),
47 | cls="ml-auto"
48 | ),
49 | cls="m-0 p-0 list-none flex flex-row flex-wrap gap-x-8 gap-y-4 justify-between items-end font-medium"
50 | ),
51 | cls="container-section"
52 | ),
53 | )
54 |
55 | def home_svg(): return NotStr('')
56 |
--------------------------------------------------------------------------------
/04-sqlite-boilerplate/utils.py:
--------------------------------------------------------------------------------
1 | from fasthtml.common import *
2 |
3 | # Status code 303 is a redirect that can change POST to GET, so it's appropriate for a login page.
4 | login_redir = RedirectResponse('/login', status_code=303)
5 | home_redir = RedirectResponse('/', status_code=303)
6 |
7 | @dataclass
8 | class Login: name:str; pwd:str
9 |
10 | def n_words(text, n):
11 | if not text: return ''
12 | words = text.split()
13 | if len(words) <= n:
14 | return text
15 | trimmed_text = ' '.join(words[:n])
16 | return NotStr(trimmed_text + '…')
17 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Example FastHTML Apps
2 |
3 | This is (or will be) a collection of demo FastHTML apps I'm creating to help me learn HTMX, FastHTML, SQLite and other related technologies.
4 |
5 | Feel free to suggest an app! Just open a new issue.
6 |
7 | Most of the examples have live reload enabled which provides a really nice developer experience. Every time you update Python code or a Tailwind class the app recompiles and live reloads almost instantly in the browser!
8 |
9 | # [1. Tailwind in FastHTML](/01-tailwind-basic/)
10 |
11 | Basic example of integrating TailwindCSS in a FastHTML app.
12 |
13 |
14 |
15 | # [2. Card Matching Memory Game](/02-card-memory-game/)
16 |
17 | A fun card matching memory game built with HTMX, FastHTML, and AlpineJS.
18 |
19 |
20 |
21 | # [3. Todo List With Multiple Users](/03-todo-list/)
22 |
23 | A todo list app with multiple users and authentication.
24 |
--------------------------------------------------------------------------------
/assets/01-tw-thumb.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/assets/01-tw-thumb.png
--------------------------------------------------------------------------------
/assets/02-card-game.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dgwyer/fasthtml-demos/2d990d259eca48ac1578ef8b191049bc9e6d12f9/assets/02-card-game.png
--------------------------------------------------------------------------------