Go + HTMX + Tailwind Example
++ Companies +
diff --git a/.gitignore b/.gitignore index f3cceee..a817cd4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ app *.swp *.test *.out +tmp diff --git a/Dockerfile b/Dockerfile index 950cc51..925e9ae 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM golang:1.19 AS build +FROM golang:1.20.4 AS build WORKDIR /go/src/app COPY . . ENV CGO_ENABLED=0 GOOS=linux diff --git a/Makefile b/Makefile index 5a7d7b9..5118a24 100644 --- a/Makefile +++ b/Makefile @@ -10,6 +10,13 @@ help: Makefile @sed -n 's/^##//p' $< | column -t -s ':' | sed -e 's/^/ /' @echo +## init: initialize project (make init module=github.com/user/project) +.PHONY: init +init: + go mod init ${module} + go install github.com/cosmtrek/air@latest + asdf reshim golang + ## vet: vet code .PHONY: vet vet: @@ -25,12 +32,6 @@ test: build: test go build -o ./app -v -## autobuild: auto build when source files change -.PHONY: autobuild -autobuild: - # curl -sf https://gobinaries.com/cespare/reflex | sh - reflex -g '*.go' -- sh -c 'echo "\n\n\n\n\n\n" && make build' - ## dockerbuild: build project into a docker container image .PHONY: dockerbuild dockerbuild: test @@ -39,12 +40,14 @@ dockerbuild: test ## start: build and run local project .PHONY: start start: build - clear - @echo "" - ./app + air -## deploy: build code into a container and deploy it to the cloud dev environment -.PHONY: deploy -deploy: build - ./deploy.sh +## css: build tailwindcss +.PHONY: css +css: + tailwindcss -i css/input.css -o css/output.css --minify +## css-watch: watch build tailwindcss +.PHONY: css-watch +css-watch: + tailwindcss -i css/input.css -o css/output.css --watch diff --git a/README.md b/README.md index 92a9d3d..5aae05f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,23 @@ -# go-template +# go-htmx-tailwind-example -Quick starter template for new go projects +Example CRUD app written in Go + HTMX + Tailwind CSS -```sh -go mod init app -``` +This project implements a pure dynamic web app with SPA-like features but without heavy complex Javascript or Go frameworks to keep up with. Just HTML/CSS + Go ❤️ + + + + +## Develop ``` Choose a make command to run + init initialize project (make init module=github.com/user/project) vet vet code test run unit tests build build a binary - autobuild auto build when source files change dockerbuild build project into a docker container image start build and run local project - deploy build code into a container and deploy it to the cloud dev environment + css build tailwindcss + css-watch watch build tailwindcss ``` diff --git a/css/input.css b/css/input.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/css/input.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/css/output.css b/css/output.css new file mode 100644 index 0000000..76e479a --- /dev/null +++ b/css/output.css @@ -0,0 +1,972 @@ +/* +! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com +*/ + +/* +1. Prevent padding and border from affecting element width. (https://github.com/mozdevs/cssremedy/issues/4) +2. Allow adding a border to an element by just adding a border-width. (https://github.com/tailwindcss/tailwindcss/pull/116) +*/ + +*, +::before, +::after { + box-sizing: border-box; + /* 1 */ + border-width: 0; + /* 2 */ + border-style: solid; + /* 2 */ + border-color: #e5e7eb; + /* 2 */ +} + +::before, +::after { + --tw-content: ''; +} + +/* +1. Use a consistent sensible line-height in all browsers. +2. Prevent adjustments of font size after orientation changes in iOS. +3. Use a more readable tab size. +4. Use the user's configured `sans` font-family by default. +5. Use the user's configured `sans` font-feature-settings by default. +6. Use the user's configured `sans` font-variation-settings by default. +*/ + +html { + line-height: 1.5; + /* 1 */ + -webkit-text-size-adjust: 100%; + /* 2 */ + -moz-tab-size: 4; + /* 3 */ + -o-tab-size: 4; + tab-size: 4; + /* 3 */ + 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"; + /* 4 */ + font-feature-settings: normal; + /* 5 */ + font-variation-settings: normal; + /* 6 */ +} + +/* +1. Remove the margin in all browsers. +2. Inherit line-height from `html` so users can set them as a class directly on the `html` element. +*/ + +body { + margin: 0; + /* 1 */ + line-height: inherit; + /* 2 */ +} + +/* +1. Add the correct height in Firefox. +2. Correct the inheritance of border color in Firefox. (https://bugzilla.mozilla.org/show_bug.cgi?id=190655) +3. Ensure horizontal rules are visible by default. +*/ + +hr { + height: 0; + /* 1 */ + color: inherit; + /* 2 */ + border-top-width: 1px; + /* 3 */ +} + +/* +Add the correct text decoration in Chrome, Edge, and Safari. +*/ + +abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; +} + +/* +Remove the default font size and weight for headings. +*/ + +h1, +h2, +h3, +h4, +h5, +h6 { + font-size: inherit; + font-weight: inherit; +} + +/* +Reset links to optimize for opt-in styling instead of opt-out. +*/ + +a { + color: inherit; + text-decoration: inherit; +} + +/* +Add the correct font weight in Edge and Safari. +*/ + +b, +strong { + font-weight: bolder; +} + +/* +1. Use the user's configured `mono` font family by default. +2. Correct the odd `em` font sizing in all browsers. +*/ + +code, +kbd, +samp, +pre { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + /* 1 */ + font-size: 1em; + /* 2 */ +} + +/* +Add the correct font size in all browsers. +*/ + +small { + font-size: 80%; +} + +/* +Prevent `sub` and `sup` elements from affecting the line height in all browsers. +*/ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* +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) +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) +3. Remove gaps between table borders by default. +*/ + +table { + text-indent: 0; + /* 1 */ + border-color: inherit; + /* 2 */ + border-collapse: collapse; + /* 3 */ +} + +/* +1. Change the font styles in all browsers. +2. Remove the margin in Firefox and Safari. +3. Remove default padding in all browsers. +*/ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; + /* 1 */ + font-size: 100%; + /* 1 */ + font-weight: inherit; + /* 1 */ + line-height: inherit; + /* 1 */ + color: inherit; + /* 1 */ + margin: 0; + /* 2 */ + padding: 0; + /* 3 */ +} + +/* +Remove the inheritance of text transform in Edge and Firefox. +*/ + +button, +select { + text-transform: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Remove default button styles. +*/ + +button, +[type='button'], +[type='reset'], +[type='submit'] { + -webkit-appearance: button; + /* 1 */ + background-color: transparent; + /* 2 */ + background-image: none; + /* 2 */ +} + +/* +Use the modern Firefox focus style for all focusable elements. +*/ + +:-moz-focusring { + outline: auto; +} + +/* +Remove the additional `:invalid` styles in Firefox. (https://github.com/mozilla/gecko-dev/blob/2f9eacd9d3d995c937b4251a5557d95d494c9be1/layout/style/res/forms.css#L728-L737) +*/ + +:-moz-ui-invalid { + box-shadow: none; +} + +/* +Add the correct vertical alignment in Chrome and Firefox. +*/ + +progress { + vertical-align: baseline; +} + +/* +Correct the cursor style of increment and decrement buttons in Safari. +*/ + +::-webkit-inner-spin-button, +::-webkit-outer-spin-button { + height: auto; +} + +/* +1. Correct the odd appearance in Chrome and Safari. +2. Correct the outline style in Safari. +*/ + +[type='search'] { + -webkit-appearance: textfield; + /* 1 */ + outline-offset: -2px; + /* 2 */ +} + +/* +Remove the inner padding in Chrome and Safari on macOS. +*/ + +::-webkit-search-decoration { + -webkit-appearance: none; +} + +/* +1. Correct the inability to style clickable types in iOS and Safari. +2. Change font properties to `inherit` in Safari. +*/ + +::-webkit-file-upload-button { + -webkit-appearance: button; + /* 1 */ + font: inherit; + /* 2 */ +} + +/* +Add the correct display in Chrome and Safari. +*/ + +summary { + display: list-item; +} + +/* +Removes the default spacing and border for appropriate elements. +*/ + +blockquote, +dl, +dd, +h1, +h2, +h3, +h4, +h5, +h6, +hr, +figure, +p, +pre { + margin: 0; +} + +fieldset { + margin: 0; + padding: 0; +} + +legend { + padding: 0; +} + +ol, +ul, +menu { + list-style: none; + margin: 0; + padding: 0; +} + +/* +Prevent resizing textareas horizontally by default. +*/ + +textarea { + resize: vertical; +} + +/* +1. Reset the default placeholder opacity in Firefox. (https://github.com/tailwindlabs/tailwindcss/issues/3300) +2. Set the default placeholder color to the user's configured gray 400 color. +*/ + +input::-moz-placeholder, textarea::-moz-placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +input::placeholder, +textarea::placeholder { + opacity: 1; + /* 1 */ + color: #9ca3af; + /* 2 */ +} + +/* +Set the default cursor for buttons. +*/ + +button, +[role="button"] { + cursor: pointer; +} + +/* +Make sure disabled buttons don't get the pointer cursor. +*/ + +:disabled { + cursor: default; +} + +/* +1. Make replaced elements `display: block` by default. (https://github.com/mozdevs/cssremedy/issues/14) +2. Add `vertical-align: middle` to align replaced elements more sensibly by default. (https://github.com/jensimmons/cssremedy/issues/14#issuecomment-634934210) + This can trigger a poorly considered lint error in some tools but is included by design. +*/ + +img, +svg, +video, +canvas, +audio, +iframe, +embed, +object { + display: block; + /* 1 */ + vertical-align: middle; + /* 2 */ +} + +/* +Constrain images and videos to the parent width and preserve their intrinsic aspect ratio. (https://github.com/mozdevs/cssremedy/issues/14) +*/ + +img, +video { + max-width: 100%; + height: auto; +} + +/* Make elements with the HTML hidden attribute stay hidden by default */ + +[hidden] { + display: none; +} + +[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 { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + border-radius: 0px; + padding-top: 0.5rem; + padding-right: 0.75rem; + padding-bottom: 0.5rem; + padding-left: 0.75rem; + font-size: 1rem; + line-height: 1.5rem; + --tw-shadow: 0 0 #0000; +} + +[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 { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + border-color: #2563eb; +} + +input::-moz-placeholder, textarea::-moz-placeholder { + color: #6b7280; + opacity: 1; +} + +input::placeholder,textarea::placeholder { + color: #6b7280; + opacity: 1; +} + +::-webkit-datetime-edit-fields-wrapper { + padding: 0; +} + +::-webkit-date-and-time-value { + min-height: 1.5em; +} + +::-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 { + padding-top: 0; + padding-bottom: 0; +} + +select { + 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"); + background-position: right 0.5rem center; + background-repeat: no-repeat; + background-size: 1.5em 1.5em; + padding-right: 2.5rem; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; +} + +[multiple] { + background-image: initial; + background-position: initial; + background-repeat: unset; + background-size: initial; + padding-right: 0.75rem; + -webkit-print-color-adjust: unset; + print-color-adjust: unset; +} + +[type='checkbox'],[type='radio'] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding: 0; + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + display: inline-block; + vertical-align: middle; + background-origin: border-box; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + flex-shrink: 0; + height: 1rem; + width: 1rem; + color: #2563eb; + background-color: #fff; + border-color: #6b7280; + border-width: 1px; + --tw-shadow: 0 0 #0000; +} + +[type='checkbox'] { + border-radius: 0px; +} + +[type='radio'] { + border-radius: 100%; +} + +[type='checkbox']:focus,[type='radio']:focus { + outline: 2px solid transparent; + outline-offset: 2px; + --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); + --tw-ring-offset-width: 2px; + --tw-ring-offset-color: #fff; + --tw-ring-color: #2563eb; + --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); + --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); + box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); +} + +[type='checkbox']:checked,[type='radio']:checked { + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:checked { + 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"); +} + +[type='radio']:checked { + 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"); +} + +[type='checkbox']:checked:hover,[type='checkbox']:checked:focus,[type='radio']:checked:hover,[type='radio']:checked:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='checkbox']:indeterminate { + 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"); + border-color: transparent; + background-color: currentColor; + background-size: 100% 100%; + background-position: center; + background-repeat: no-repeat; +} + +[type='checkbox']:indeterminate:hover,[type='checkbox']:indeterminate:focus { + border-color: transparent; + background-color: currentColor; +} + +[type='file'] { + background: unset; + border-color: inherit; + border-width: 0; + border-radius: 0; + padding: 0; + font-size: unset; + line-height: inherit; +} + +[type='file']:focus { + outline: 1px solid ButtonText; + outline: 1px auto -webkit-focus-ring-color; +} + +*, ::before, ::after { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +::backdrop { + --tw-border-spacing-x: 0; + --tw-border-spacing-y: 0; + --tw-translate-x: 0; + --tw-translate-y: 0; + --tw-rotate: 0; + --tw-skew-x: 0; + --tw-skew-y: 0; + --tw-scale-x: 1; + --tw-scale-y: 1; + --tw-pan-x: ; + --tw-pan-y: ; + --tw-pinch-zoom: ; + --tw-scroll-snap-strictness: proximity; + --tw-gradient-from-position: ; + --tw-gradient-via-position: ; + --tw-gradient-to-position: ; + --tw-ordinal: ; + --tw-slashed-zero: ; + --tw-numeric-figure: ; + --tw-numeric-spacing: ; + --tw-numeric-fraction: ; + --tw-ring-inset: ; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-color: rgb(59 130 246 / 0.5); + --tw-ring-offset-shadow: 0 0 #0000; + --tw-ring-shadow: 0 0 #0000; + --tw-shadow: 0 0 #0000; + --tw-shadow-colored: 0 0 #0000; + --tw-blur: ; + --tw-brightness: ; + --tw-contrast: ; + --tw-grayscale: ; + --tw-hue-rotate: ; + --tw-invert: ; + --tw-saturate: ; + --tw-sepia: ; + --tw-drop-shadow: ; + --tw-backdrop-blur: ; + --tw-backdrop-brightness: ; + --tw-backdrop-contrast: ; + --tw-backdrop-grayscale: ; + --tw-backdrop-hue-rotate: ; + --tw-backdrop-invert: ; + --tw-backdrop-opacity: ; + --tw-backdrop-saturate: ; + --tw-backdrop-sepia: ; +} + +.m-2 { + margin: 0.5rem; +} + +.inline-block { + display: inline-block; +} + +.flex { + display: flex; +} + +.inline-flex { + display: inline-flex; +} + +.table { + display: table; +} + +.h-8 { + height: 2rem; +} + +.w-full { + width: 100%; +} + +.min-w-full { + min-width: 100%; +} + +.appearance-none { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; +} + +.flex-col { + flex-direction: column; +} + +.items-center { + align-items: center; +} + +.justify-end { + justify-content: flex-end; +} + +.overflow-hidden { + overflow: hidden; +} + +.overflow-x-auto { + overflow-x: auto; +} + +.whitespace-nowrap { + white-space: nowrap; +} + +.rounded { + border-radius: 0.25rem; +} + +.rounded-lg { + border-radius: 0.5rem; +} + +.border { + border-width: 1px; +} + +.border-b { + border-bottom-width: 1px; +} + +.bg-blue-700 { + --tw-bg-opacity: 1; + background-color: rgb(29 78 216 / var(--tw-bg-opacity)); +} + +.bg-red-700 { + --tw-bg-opacity: 1; + background-color: rgb(185 28 28 / var(--tw-bg-opacity)); +} + +.px-1 { + padding-left: 0.25rem; + padding-right: 0.25rem; +} + +.px-3 { + padding-left: 0.75rem; + padding-right: 0.75rem; +} + +.px-4 { + padding-left: 1rem; + padding-right: 1rem; +} + +.px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; +} + +.py-1 { + padding-top: 0.25rem; + padding-bottom: 0.25rem; +} + +.py-2 { + padding-top: 0.5rem; + padding-bottom: 0.5rem; +} + +.py-4 { + padding-top: 1rem; + padding-bottom: 1rem; +} + +.text-left { + text-align: left; +} + +.text-3xl { + font-size: 1.875rem; + line-height: 2.25rem; +} + +.text-sm { + font-size: 0.875rem; + line-height: 1.25rem; +} + +.text-xl { + font-size: 1.25rem; + line-height: 1.75rem; +} + +.font-light { + font-weight: 300; +} + +.font-medium { + font-weight: 500; +} + +.leading-tight { + line-height: 1.25; +} + +.text-blue-100 { + --tw-text-opacity: 1; + color: rgb(219 234 254 / var(--tw-text-opacity)); +} + +.text-blue-700 { + --tw-text-opacity: 1; + color: rgb(29 78 216 / var(--tw-text-opacity)); +} + +.text-gray-700 { + --tw-text-opacity: 1; + color: rgb(55 65 81 / var(--tw-text-opacity)); +} + +.text-red-100 { + --tw-text-opacity: 1; + color: rgb(254 226 226 / var(--tw-text-opacity)); +} + +.shadow { + --tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); + --tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color); + box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); +} + +.transition-colors { + transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); + transition-duration: 150ms; +} + +.duration-150 { + transition-duration: 150ms; +} + +@media (min-width: 768px) { + .md\:container { + width: 100%; + } + + @media (min-width: 640px) { + .md\:container { + max-width: 640px; + } + } + + @media (min-width: 768px) { + .md\:container { + max-width: 768px; + } + } + + @media (min-width: 1024px) { + .md\:container { + max-width: 1024px; + } + } + + @media (min-width: 1280px) { + .md\:container { + max-width: 1280px; + } + } + + @media (min-width: 1536px) { + .md\:container { + max-width: 1536px; + } + } +} + +.hover\:bg-blue-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(30 64 175 / var(--tw-bg-opacity)); +} + +.hover\:bg-red-800:hover { + --tw-bg-opacity: 1; + background-color: rgb(153 27 27 / var(--tw-bg-opacity)); +} + +.hover\:underline:hover { + text-decoration-line: underline; +} + +.focus\:outline-none:focus { + outline: 2px solid transparent; + outline-offset: 2px; +} + +@media (prefers-color-scheme: dark) { + .dark\:border-neutral-500 { + --tw-border-opacity: 1; + border-color: rgb(115 115 115 / var(--tw-border-opacity)); + } +} + +@media (min-width: 640px) { + .sm\:-mx-6 { + margin-left: -1.5rem; + margin-right: -1.5rem; + } + + .sm\:px-6 { + padding-left: 1.5rem; + padding-right: 1.5rem; + } +} + +@media (min-width: 768px) { + .md\:mx-auto { + margin-left: auto; + margin-right: auto; + } +} + +@media (min-width: 1024px) { + .lg\:-mx-8 { + margin-left: -2rem; + margin-right: -2rem; + } + + .lg\:px-8 { + padding-left: 2rem; + padding-right: 2rem; + } +} \ No newline at end of file diff --git a/data.go b/data.go new file mode 100644 index 0000000..70192d4 --- /dev/null +++ b/data.go @@ -0,0 +1,88 @@ +package main + +import "strconv" + +var data []Company + +type Company struct { + ID string + Company string + Contact string + Country string +} + +func init() { + data = []Company{ + { + ID: "1", + Company: "Amazon", + Contact: "Jeff Bezos", + Country: "United States", + }, + { + ID: "2", + Company: "Apple", + Contact: "Tim Cook", + Country: "United States", + }, + { + ID: "3", + Company: "Microsoft", + Contact: "Satya Nadella", + Country: "United States", + }, + } +} + +func getCompanyByID(id string) Company { + var result Company + for _, i := range data { + if i.ID == id { + result = i + break + } + } + return result +} + +func updateCompany(company Company) { + result := []Company{} + for _, i := range data { + if i.ID == company.ID { + i.Company = company.Company + i.Contact = company.Contact + i.Country = company.Country + } + result = append(result, i) + } + data = result +} + +func addCompany(company Company) { + max := 0 + for _, i := range data { + n, _ := strconv.Atoi(i.ID) + if n > max { + max = n + } + } + max++ + id := strconv.Itoa(max) + + data = append(data, Company{ + ID: id, + Company: company.Company, + Contact: company.Contact, + Country: company.Country, + }) +} + +func deleteCompany(id string) { + result := []Company{} + for _, i := range data { + if i.ID != id { + result = append(result, i) + } + } + data = result +} diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index f566e4c..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,7 +0,0 @@ -version: "3.7" -services: - app: - build: . - image: app:0.1.0 - environment: - FOO: bar diff --git a/go.mod b/go.mod index d38f91b..d318025 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/jritsema/go-htmx-starter go 1.20 + +require github.com/jritsema/gotoolbox v0.8.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..f0cc2d1 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/jritsema/gotoolbox v0.7.0 h1:pvEf4VnB/Gjf/UYoq8T6f9vnIb7ssteGlFJEXg0Ejpo= +github.com/jritsema/gotoolbox v0.7.0/go.mod h1:OgV4sjpMB/bx/ZZPpXWvfalGrniFvkvGtqFRQH6GGHY= +github.com/jritsema/gotoolbox v0.8.0 h1:guUvlilrUcT24i0iGnasLch6pjWJ437Qnabk6WTmPEU= +github.com/jritsema/gotoolbox v0.8.0/go.mod h1:OgV4sjpMB/bx/ZZPpXWvfalGrniFvkvGtqFRQH6GGHY= diff --git a/logging.go b/logging.go new file mode 100644 index 0000000..969a72c --- /dev/null +++ b/logging.go @@ -0,0 +1,40 @@ +package main + +import ( + "context" + "log" + "net/http" +) + +type key int + +const requestIDKey key = 0 + +func logging(logger *log.Logger) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + requestID, ok := r.Context().Value(requestIDKey).(string) + if !ok { + requestID = "unknown" + } + logger.Println(requestID, r.Method, r.URL.Path, r.RemoteAddr, r.UserAgent()) + }() + next.ServeHTTP(w, r) + }) + } +} + +func tracing(nextRequestID func() string) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestID := r.Header.Get("X-Request-Id") + if requestID == "" { + requestID = nextRequestID() + } + ctx := context.WithValue(r.Context(), requestIDKey, requestID) + w.Header().Set("X-Request-Id", requestID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} diff --git a/main.go b/main.go index 2498eb9..e79bb9d 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,29 @@ package main import ( + "embed" "fmt" + "html/template" + "log" + "net/http" "os" "os/signal" "syscall" + "time" + + "github.com/jritsema/gotoolbox" + "github.com/jritsema/gotoolbox/web" +) + +var ( + //go:embed all:templates/* + templateFS embed.FS + + //go:embed css/output.css + css embed.FS + + //parsed templates + html *template.Template ) func main() { @@ -12,6 +31,42 @@ func main() { //exit process immediately upon sigterm handleSigTerms() + //parse templates + var err error + html, err = web.TemplateParseFSRecursive(templateFS, ".html", true, nil) + if err != nil { + panic(err) + } + + //add routes + router := http.NewServeMux() + router.Handle("/css/output.css", http.FileServer(http.FS(css))) + + router.Handle("/company/add", web.Action(companyAdd)) + router.Handle("/company/add/", web.Action(companyAdd)) + + router.Handle("/company/edit", web.Action(companyEdit)) + router.Handle("/company/edit/", web.Action(companyEdit)) + + router.Handle("/company", web.Action(companies)) + router.Handle("/company/", web.Action(companies)) + + router.Handle("/", web.Action(index)) + router.Handle("/index.html", web.Action(index)) + + //logging/tracing + nextRequestID := func() string { + return fmt.Sprintf("%d", time.Now().UnixNano()) + } + logger := log.New(os.Stdout, "http: ", log.LstdFlags) + middleware := tracing(nextRequestID)(logging(logger)(router)) + + port := gotoolbox.GetEnvWithDefault("PORT", "8080") + logger.Println("listening on http://localhost:" + port) + if err := http.ListenAndServe(":"+port, middleware); err != nil { + logger.Println("http.ListenAndServe():", err) + os.Exit(1) + } } func handleSigTerms() { diff --git a/routes.go b/routes.go new file mode 100644 index 0000000..fa35ea0 --- /dev/null +++ b/routes.go @@ -0,0 +1,81 @@ +package main + +import ( + "net/http" + + "github.com/jritsema/gotoolbox/web" +) + +// Delete -> DELETE /company/{id} -> delete, companys.html + +// Edit -> GET /company/edit/{id} -> row-edit.html +// Save -> PUT /company/{id} -> update, row.html +// Cancel -> GET /company/{id} -> nothing, row.html + +// Add -> GET /company/add/ -> companys-add.html (target body with row-add.html and row.html) +// Save -> POST /company -> add, companys.html (target body without row-add.html) +// Cancel -> GET /company -> nothing, companys.html + +func index(r *http.Request) *web.Response { + return web.HTML(http.StatusOK, html, "index.html", data, nil) +} + +// GET /company/add +func companyAdd(r *http.Request) *web.Response { + return web.HTML(http.StatusOK, html, "company-add.html", data, nil) +} + +// /GET company/edit/{id} +func companyEdit(r *http.Request) *web.Response { + id, _ := web.PathLast(r) + row := getCompanyByID(id) + return web.HTML(http.StatusOK, html, "row-edit.html", row, nil) +} + +// GET /company +// GET /company/{id} +// DELETE /company/{id} +// PUT /company/{id} +// POST /company +func companies(r *http.Request) *web.Response { + id, segments := web.PathLast(r) + switch r.Method { + + case http.MethodDelete: + deleteCompany(id) + return web.HTML(http.StatusOK, html, "companies.html", data, nil) + + //cancel + case http.MethodGet: + if segments > 1 { + //cancel edit + row := getCompanyByID(id) + return web.HTML(http.StatusOK, html, "row.html", row, nil) + } else { + //cancel add + return web.HTML(http.StatusOK, html, "companies.html", data, nil) + } + + //save edit + case http.MethodPut: + row := getCompanyByID(id) + r.ParseForm() + row.Company = r.Form.Get("company") + row.Contact = r.Form.Get("contact") + row.Country = r.Form.Get("country") + updateCompany(row) + return web.HTML(http.StatusOK, html, "row.html", row, nil) + + //save add + case http.MethodPost: + row := Company{} + r.ParseForm() + row.Company = r.Form.Get("company") + row.Contact = r.Form.Get("contact") + row.Country = r.Form.Get("country") + addCompany(row) + return web.HTML(http.StatusOK, html, "companies.html", data, nil) + } + + return web.Empty(http.StatusNotImplemented) +} diff --git a/screenshot.jpeg b/screenshot.jpeg new file mode 100644 index 0000000..7461d46 Binary files /dev/null and b/screenshot.jpeg differ diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..354e7fe --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +module.exports = { + content: ["./templates/**/*.{html,js}"], + theme: { + extend: {}, + }, + plugins: [ + require('@tailwindcss/forms'), + require('@tailwindcss/typography'), + ], +} + diff --git a/templates/companies.html b/templates/companies.html new file mode 100644 index 0000000..cbf5b65 --- /dev/null +++ b/templates/companies.html @@ -0,0 +1,38 @@ +
| # | +Company | +Contact | +Country | +
|---|