Swap Bulma for DaisyUI (Tailwind) (#111)
This commit is contained in:
parent
fc5db0e95a
commit
c1e9baabe6
53 changed files with 1124 additions and 632 deletions
|
|
@ -5,16 +5,16 @@ tmp_dir = "tmp"
|
||||||
[build]
|
[build]
|
||||||
args_bin = []
|
args_bin = []
|
||||||
bin = "./tmp/main"
|
bin = "./tmp/main"
|
||||||
cmd = "go build -o ./tmp/main ./cmd/web"
|
cmd = "make build"
|
||||||
delay = 1000
|
delay = 1000
|
||||||
exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs"]
|
exclude_dir = ["assets", "tmp", "vendor", "testdata", "uploads", "dbs", "public"]
|
||||||
exclude_file = []
|
exclude_file = []
|
||||||
exclude_regex = ["_test.go"]
|
exclude_regex = ["_test.go"]
|
||||||
exclude_unchanged = false
|
exclude_unchanged = false
|
||||||
follow_symlink = false
|
follow_symlink = false
|
||||||
full_bin = ""
|
full_bin = ""
|
||||||
include_dir = []
|
include_dir = []
|
||||||
include_ext = ["go", "tpl", "tmpl", "html"]
|
include_ext = ["go", "tpl", "tmpl", "html", "css"]
|
||||||
include_file = []
|
include_file = []
|
||||||
kill_delay = "0s"
|
kill_delay = "0s"
|
||||||
log = "build-errors.log"
|
log = "build-errors.log"
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@
|
||||||
dbs
|
dbs
|
||||||
uploads
|
uploads
|
||||||
tmp
|
tmp
|
||||||
|
tailwindcss
|
||||||
|
daisyui*
|
||||||
21
Makefile
21
Makefile
|
|
@ -1,7 +1,20 @@
|
||||||
|
# The Tailwind CSS CLI package to install (pick the one that matches your OS: https://github.com/tailwindlabs/tailwindcss/releases/latest)
|
||||||
|
TAILWIND_PACKAGE = tailwindcss-linux-x64
|
||||||
|
|
||||||
.PHONY: help
|
.PHONY: help
|
||||||
help: ## Print make targets
|
help: ## Print make targets
|
||||||
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
|
||||||
|
|
||||||
|
.PHONY: install
|
||||||
|
install: ent-install air-install tailwind-install ## Install all dependencies
|
||||||
|
|
||||||
|
.PHONY: tailwind-install
|
||||||
|
tailwind-install: ## Install the Tailwind CSS CLI
|
||||||
|
curl -sLo tailwindcss https://github.com/tailwindlabs/tailwindcss/releases/latest/download/$(TAILWIND_PACKAGE)
|
||||||
|
chmod +x tailwindcss
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui.js
|
||||||
|
curl -sLO https://github.com/saadeghi/daisyui/releases/latest/download/daisyui-theme.js
|
||||||
|
|
||||||
.PHONY: ent-install
|
.PHONY: ent-install
|
||||||
ent-install: ## Install Ent code-generation module
|
ent-install: ## Install Ent code-generation module
|
||||||
go get entgo.io/ent/cmd/ent
|
go get entgo.io/ent/cmd/ent
|
||||||
|
|
@ -39,3 +52,11 @@ test: ## Run all tests
|
||||||
.PHONY: check-updates
|
.PHONY: check-updates
|
||||||
check-updates: ## Check for direct dependency updates
|
check-updates: ## Check for direct dependency updates
|
||||||
go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\["
|
go list -u -m -f '{{if not .Indirect}}{{.}}{{end}}' all | grep "\["
|
||||||
|
|
||||||
|
.PHONY: css
|
||||||
|
css: ## Build and minify Tailwind CSS
|
||||||
|
./tailwindcss -i tailwind.css -o public/static/main.css -m
|
||||||
|
|
||||||
|
.PHONY: build
|
||||||
|
build: css ## Build CSS and compile the application binary
|
||||||
|
go build -o ./tmp/main ./cmd/web
|
||||||
124
README.md
124
README.md
|
|
@ -20,6 +20,7 @@
|
||||||
* [Getting started](#getting-started)
|
* [Getting started](#getting-started)
|
||||||
* [Dependencies](#dependencies)
|
* [Dependencies](#dependencies)
|
||||||
* [Getting the code](#getting-the-code)
|
* [Getting the code](#getting-the-code)
|
||||||
|
* [Installing tools](#installing-tools)
|
||||||
* [Create an admin account](#create-an-admin-account)
|
* [Create an admin account](#create-an-admin-account)
|
||||||
* [Start the application](#start-the-application)
|
* [Start the application](#start-the-application)
|
||||||
* [Live reloading](#live-reloading)
|
* [Live reloading](#live-reloading)
|
||||||
|
|
@ -65,6 +66,7 @@
|
||||||
* [Header management](#header-management)
|
* [Header management](#header-management)
|
||||||
* [Conditional and partial rendering](#conditional-and-partial-rendering)
|
* [Conditional and partial rendering](#conditional-and-partial-rendering)
|
||||||
* [CSRF token](#csrf-token)
|
* [CSRF token](#csrf-token)
|
||||||
|
* [CSS](#css)
|
||||||
* [Request](#request)
|
* [Request](#request)
|
||||||
* [Title and metatags](#title-and-metatags)
|
* [Title and metatags](#title-and-metatags)
|
||||||
* [URL generation](#url-generation)
|
* [URL generation](#url-generation)
|
||||||
|
|
@ -77,6 +79,7 @@
|
||||||
* [Inline validation](#inline-validation)
|
* [Inline validation](#inline-validation)
|
||||||
* [CSRF](#csrf)
|
* [CSRF](#csrf)
|
||||||
* [Models](#models)
|
* [Models](#models)
|
||||||
|
* [Icons](#icons)
|
||||||
* [Node caching](#node-caching)
|
* [Node caching](#node-caching)
|
||||||
* [Flash messaging](#flash-messaging)
|
* [Flash messaging](#flash-messaging)
|
||||||
* [Pager](#pager)
|
* [Pager](#pager)
|
||||||
|
|
@ -91,9 +94,11 @@
|
||||||
* [Monitoring tasks and queues](#monitoring-tasks-and-queues)
|
* [Monitoring tasks and queues](#monitoring-tasks-and-queues)
|
||||||
* [Cron](#cron)
|
* [Cron](#cron)
|
||||||
* [Files](#files)
|
* [Files](#files)
|
||||||
* [Static files](#static-files)
|
* [Uploads](#uploads)
|
||||||
* [Cache control headers](#cache-control-headers)
|
* [Static files](#static-files)
|
||||||
* [Cache-buster](#cache-buster)
|
* [Cache-buster](#cache-buster)
|
||||||
|
* [Public files](#public-files)
|
||||||
|
* [Cache control headers](#cache-control-headers)
|
||||||
* [Email](#email)
|
* [Email](#email)
|
||||||
* [HTTPS](#https)
|
* [HTTPS](#https)
|
||||||
* [Logging](#logging)
|
* [Logging](#logging)
|
||||||
|
|
@ -125,7 +130,7 @@ Go server-side rendered HTML combined with the projects below enable you to crea
|
||||||
|
|
||||||
- [HTMX](https://htmx.org/): Access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
|
- [HTMX](https://htmx.org/): Access AJAX, CSS Transitions, WebSockets and Server Sent Events directly in HTML, using attributes, so you can build modern user interfaces with the simplicity and power of hypertext.
|
||||||
- [Alpine.js](https://alpinejs.dev/): Rugged, minimal tool for composing behavior directly in your markup. Think of it like jQuery for the modern web. Plop in a script tag and get going.
|
- [Alpine.js](https://alpinejs.dev/): Rugged, minimal tool for composing behavior directly in your markup. Think of it like jQuery for the modern web. Plop in a script tag and get going.
|
||||||
- [Bulma](https://bulma.io/): Provides ready-to-use frontend components that you can easily combine to build responsive web interfaces. No JavaScript dependencies.
|
- [DaisyUI](https://daisyui.com/): The [Tailwind CSS](https://tailwindcss.com/) plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript dependencies.
|
||||||
|
|
||||||
#### Storage
|
#### Storage
|
||||||
|
|
||||||
|
|
@ -174,9 +179,17 @@ git clone git@github.com:mikestefanello/pagoda.git
|
||||||
cd pagoda
|
cd pagoda
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Installing tools
|
||||||
|
|
||||||
|
Several optional tools are available to make development easier for you. This includes [Ent](#orm) code-generator, for generating ORM code, [Air](https://github.com/air-verse/air) CLI, to provide [live reloading](#live-reloading), and [Tailwind CSS](https://tailwindcss.com/docs/installation/tailwind-cli) CLI, to generate CSS.
|
||||||
|
|
||||||
|
If you wish to use Tailwind (with or without [Daisy UI](https://daisyui.com/)), modify the [Makefile](https://github.com/mikestefanello/pagoda/blob/main/Makefile), and adjust the `TAILWIND_PACKAGE` variable to reference the proper package for your operating system. By default, it's set to work with Linux x64. If you want to use Tailwind but don't want to use the standalone CLI, ie `npm`, modify the `tailwind-install` and `css` _make targets_ based on your preferences.
|
||||||
|
|
||||||
|
To easily install all tools, run `make install` from the root of the repo. There are also separate _make targets_ for each tool (run `make help` to list all targets).
|
||||||
|
|
||||||
### Create an admin account
|
### Create an admin account
|
||||||
|
|
||||||
In order to access the [admin panel](#admin-panel), you must log in with an admin user and in order to create your first admin user account, you must use the command-line. Execute `make admin email=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
|
To access the [admin panel](#admin-panel), you must log in with an admin user and to create your first admin user account, you must use the command-line. Execute `make admin email=your@email.com` from the root of the codebase, and an admin account will be generated using that email address. The console will print the randomly-generated password for the account.
|
||||||
|
|
||||||
Once you have one admin account, you can use that account to manage other users and admins from within the UI.
|
Once you have one admin account, you can use that account to manage other users and admins from within the UI.
|
||||||
|
|
||||||
|
|
@ -190,7 +203,9 @@ These settings, and many others, can be changed via the [configuration](#configu
|
||||||
|
|
||||||
### Live reloading
|
### Live reloading
|
||||||
|
|
||||||
Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [air](https://github.com/air-verse/air) by running `make air-install`, then use `make watch` to start the application with automatic live reloading.
|
Rather than using `make run`, if you prefer live reloading so your app automatically rebuilds and runs whenever you save code changes, start by installing [Air](https://github.com/air-verse/air), if you haven't already, by running `make air-install` (or `make install` to install all tools), then use `make watch` to start the application with automatic live reloading.
|
||||||
|
|
||||||
|
When code changes are detected, `make css` will run to re-compile Tailwind styles automatically before restarting the app. If you choose not to use Tailwind, either modify `make css` to run whatever commands you require, or remove this from the `make build` target.
|
||||||
|
|
||||||
## Service container
|
## Service container
|
||||||
|
|
||||||
|
|
@ -623,6 +638,14 @@ if htmx.GetRequest(ctx).Target == "search" {
|
||||||
|
|
||||||
If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events).
|
If [CSRF](#csrf) protection is enabled, the token value will automatically be passed to HTMX to be included in all non-GET requests. This is done in the `JS()` [component](#components) by leveraging HTMX [events](https://htmx.org/reference/#events).
|
||||||
|
|
||||||
|
### CSS
|
||||||
|
|
||||||
|
[DaisyUI](https://daisyui.com/), which is a component library for [Tailwind CSS](https://tailwindcss.com/), was chosen as the default CSS solution. You are not required to use either of these and removing what has been provided should be quite simple. Both of these tools are very mature, have huge communities, and endless resources, making them ideal for rapid, simple frontend development
|
||||||
|
|
||||||
|
Review the [installing tools](#installing-tools) section to ensure you have everything installed, and you understand how the `make` commands handle building your CSS styles and how live reloading automatically handles executing the Tailwind CLI. By default, the compiled CSS is written to `public/static/main.css` when `make css` is executed.
|
||||||
|
|
||||||
|
Tailwind configuration is stored in [tailwind.css](https://github.com/mikestefanello/pagoda/blob/main/tailwind.css) and is configured to check for classes within `pkg/ui`. Remember, full class names must be present in the Go files in order for Tailwind to find them; you cannot dynamically build classes.
|
||||||
|
|
||||||
### Request
|
### Request
|
||||||
|
|
||||||
The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires.
|
The `Request` type in the `ui` package is a foundational helper that provides useful data from the current request as well as resources and methods that make rendering UI components much easier. Using the `echo.Context`, a `Request` can be generated by executing `ui.NewRequest(ctx)`. As you develop and expand your application, you will likely want to expand this type to include additional data and methods that your frontend requires.
|
||||||
|
|
@ -676,7 +699,40 @@ func ProfileLink(r *ui.Request, userName string, userID int64) gomponents.Node {
|
||||||
|
|
||||||
The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation).
|
The [components package](https://github.com/mikestefanello/pagoda/tree/templates/pkg/ui/components) is meant to be your library of reusable _gomponent_ components. Having this makes building your [layouts](#layouts), [pages](#pages), [forms](#forms), [models](#models) and the rest of your user interface much easier. Some of the examples provided include components for [flash messages](#flash-messaging), navigation menus, tabs, metatags, and form elements used to automatically provide [inline validation](#inline-form-validation).
|
||||||
|
|
||||||
Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements.
|
Your components can also make using utility-based CSS libraries, such as [Tailwind CSS](https://tailwindcss.com/), much easier by avoiding excessive duplication of classes across elements. A number of [DaisyUI](https://daisyui.com/) components are provided.
|
||||||
|
|
||||||
|
Here are some examples:
|
||||||
|
|
||||||
|
```go
|
||||||
|
Badge(ColorSuccess, "Hello")
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
Card(CardParams{
|
||||||
|
Title: "Hello world",
|
||||||
|
Body: Group{
|
||||||
|
Span(Text("This is a card.")),
|
||||||
|
},
|
||||||
|
Footer: Group{
|
||||||
|
ButtonLink(ColorNeutral, "https://daisyui.com", "Learn more"),
|
||||||
|
},
|
||||||
|
Color: ColorInfo,
|
||||||
|
Size: SizeMedium,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
Tabs([]Tab{
|
||||||
|
{
|
||||||
|
Title: "Tab 1",
|
||||||
|
Body: "Here is tab 1.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: "Tab 2",
|
||||||
|
Body: "Check out tab 2.",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
### Layouts
|
### Layouts
|
||||||
|
|
||||||
|
|
@ -740,7 +796,7 @@ func (f *Guestbook) Render(r *ui.Request) Node {
|
||||||
Value: f.Message,
|
Value: f.Message,
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Submit"),
|
FormButton(ColorPrimary, "Submit"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
@ -834,6 +890,19 @@ The `Request` automatically extracts the CSRF token from the context, but you mu
|
||||||
|
|
||||||
Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example.
|
Models are objects built and provided by your _routes_ that can be rendered by your _ui_. Though not required, they reside in the [models package](https://github.com/mikestefanello/pagoda/tree/main/pkg/ui/models) and each has a `Render()` method, making them easy to render within your [pages](#pages). Please see example routes such as the homepage and search for an example.
|
||||||
|
|
||||||
|
### Icons
|
||||||
|
|
||||||
|
A starting SVG icon library is provided in `pkg/ui/icons` with icons from [heroicons](https://heroicons.com/). You are free to use any icons you want and in any manner.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```go
|
||||||
|
A(
|
||||||
|
Href("/user/123"),
|
||||||
|
icons.UserCircle,
|
||||||
|
Text("Profile")
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
### Node caching
|
### Node caching
|
||||||
|
|
||||||
While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change.
|
While most likely unnecessary for most applications, but because optimizing software is fun, a simple `gomponents.Node` cache is provided. This is not because _gomponents_ is inefficient, in fact my basic benchmarks put it as either similar or slightly better than Go templates, but rather because there are _some_ performance gains to be seen by caching static nodes and it may seem wasteful to build and render static HTML on every single page load. It is important to note, you can only cache nodes that are static and will never change.
|
||||||
|
|
@ -868,11 +937,11 @@ There are four types of messages, and each can be created as follows:
|
||||||
- Success: `msg.Success(ctx echo.Context, message string)`
|
- Success: `msg.Success(ctx echo.Context, message string)`
|
||||||
- Info: `msg.Info(ctx echo.Context, message string)`
|
- Info: `msg.Info(ctx echo.Context, message string)`
|
||||||
- Warning: `msg.Warning(ctx echo.Context, message string)`
|
- Warning: `msg.Warning(ctx echo.Context, message string)`
|
||||||
- Danger: `msg.Danger(ctx echo.Context, message string)`
|
- Error: `msg.Error(ctx echo.Context, message string)`
|
||||||
|
|
||||||
#### Rendering messages
|
#### Rendering messages
|
||||||
|
|
||||||
When a flash message is retrieved from storage in order to be rendered, it is deleted from storage so that it cannot be rendered again.
|
When a flash message is retrieved from storage to be rendered, it is deleted from storage so that it cannot be rendered again.
|
||||||
|
|
||||||
A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
|
A [component](#components), `FlashMessages()`, is provided to render flash messages within your UI.
|
||||||
|
|
||||||
|
|
@ -1007,36 +1076,44 @@ By default, no cron solution is provided because it's very easy to add yourself
|
||||||
|
|
||||||
## Files
|
## Files
|
||||||
|
|
||||||
|
Providing a generic approach to files that works for all use-cases is nearly impossible, so the defaults provided here is more of an illustration of what features are available. It's likely you will want to use a CDN and/or cloud storage for all or some of your file management.
|
||||||
|
|
||||||
|
### Uploads
|
||||||
|
|
||||||
To handle file management functionality such as file uploads, an abstracted file system interface is provided as a _Service_ on the `Container` powered by [afero](https://github.com/spf13/afero). This allows you to easily change the file system backend (ie, local, GCS, SFTP, in-memory) without having to change any of the application code other than the initialization on the `Container`. By default, the local OS is used with a directory specified in the application configuration (which defaults to `uploads`). When running tests, an in-memory file system backend is automatically used.
|
To handle file management functionality such as file uploads, an abstracted file system interface is provided as a _Service_ on the `Container` powered by [afero](https://github.com/spf13/afero). This allows you to easily change the file system backend (ie, local, GCS, SFTP, in-memory) without having to change any of the application code other than the initialization on the `Container`. By default, the local OS is used with a directory specified in the application configuration (which defaults to `uploads`). When running tests, an in-memory file system backend is automatically used.
|
||||||
|
|
||||||
A simple file upload form example is provided at `/files` which also dynamically lists all files previously uploaded. No database entities or entries are created or provided for files and uploaded files are not available to be served. You will have to implement whatever functionality your application needs.
|
A simple file upload form example is provided at `/files` which also dynamically lists all files previously uploaded. No database entities or entries are created or provided for files and uploaded files are not available to be served. You will have to implement whatever functionality your application needs.
|
||||||
|
|
||||||
## Static files
|
### Static files
|
||||||
|
|
||||||
Static files are currently configured in the router (`pkg/handler/router.go`) to be served from the `static` directory. If you wish to change the directory, alter the constant `config.StaticDir`. The URL prefix for static files is `/files` which is controlled via the `config.StaticPrefix` constant.
|
Static files, which are intended to be CSS, JS, UI images, etc, are currently configured in the router (`pkg/handler/router.go`) to be served from the `public/static` directory and are available via the URL prefix `/static`. By default, `make css` will write your css file here.
|
||||||
|
|
||||||
### Cache control headers
|
#### Cache-buster
|
||||||
|
|
||||||
Static files are grouped separately so you can apply middleware only to them. Included is a custom middleware to set cache control headers (`middleware.CacheControl`) which has been added to the static files router group.
|
While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. To do this, a function, `StaticFile()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
|
||||||
|
|
||||||
The cache max-life is controlled by the configuration at `Config.Cache.Expiration.StaticFile` and defaults to 6 months.
|
For example, to render a file located in `public/static/picture.png`, you would use:
|
||||||
|
|
||||||
### Cache-buster
|
|
||||||
|
|
||||||
While it's ideal to use cache control headers on your static files so browsers cache the files, you need a way to bust the cache in case the files are changed. In order to do this, a function, `File()`, is provided in the `ui` package to generate a static file URL for a given file that appends a cache-buster query. This query string is generated using the timestamp of when the app started and persists until the application restarts.
|
|
||||||
|
|
||||||
For example, to render a file located in `static/picture.png`, you would use:
|
|
||||||
```go
|
```go
|
||||||
return Img(Src(ui.File("picture.png")))
|
return Img(Src(ui.StaticFile("picture.png")))
|
||||||
```
|
```
|
||||||
|
|
||||||
Which would result in:
|
Which would result in:
|
||||||
```html
|
```html
|
||||||
<img src="/files/picture.png?v=1741053493"/>
|
<img src="/static/picture.png?v=1741053493"/>
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `1741053493` is the cache-buster.
|
Where `1741053493` is the cache-buster.
|
||||||
|
|
||||||
|
### Public files
|
||||||
|
|
||||||
|
An additional option for public files that do not fit in to the previously mentioned categories is provided as an example. Defined in the router, files located in `public/files` are available via the URL prefix `/files`. `ui.PublicFile()` can be used to generate URLs for files within this directory.
|
||||||
|
|
||||||
|
### Cache control headers
|
||||||
|
|
||||||
|
Static and public files are grouped separately so you can apply middleware only to them. Included is a custom middleware to set cache control headers (`middleware.CacheControl`) which has been added to the static files router group.
|
||||||
|
|
||||||
|
The cache max-life is controlled by the configuration at `Config.Cache.Expiration.PublicFile` and defaults to 6 months.
|
||||||
|
|
||||||
## Email
|
## Email
|
||||||
|
|
||||||
An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
|
An email client was added as a _Service_ to the `Container` but it is just a skeleton without any actual email-sending functionality. The reason is that there are a lot of ways to send email and most prefer using a SaaS solution for that. That makes it difficult to provide a generic solution that will work for most applications.
|
||||||
|
|
@ -1137,7 +1214,7 @@ Thank you to all the following amazing projects for making this possible.
|
||||||
- [air](https://github.com/air-verse/air)
|
- [air](https://github.com/air-verse/air)
|
||||||
- [alpinejs](https://github.com/alpinejs/alpine)
|
- [alpinejs](https://github.com/alpinejs/alpine)
|
||||||
- [backlite](https://github.com/mikestefanello/backlite)
|
- [backlite](https://github.com/mikestefanello/backlite)
|
||||||
- [bulma](https://github.com/jgthms/bulma)
|
- [daisyui](https://github.com/saadeghi/daisyui)
|
||||||
- [echo](https://github.com/labstack/echo)
|
- [echo](https://github.com/labstack/echo)
|
||||||
- [ent](https://github.com/ent/ent)
|
- [ent](https://github.com/ent/ent)
|
||||||
- [go](https://go.dev/)
|
- [go](https://go.dev/)
|
||||||
|
|
@ -1149,6 +1226,7 @@ Thank you to all the following amazing projects for making this possible.
|
||||||
- [otter](https://github.com/maypok86/otter)
|
- [otter](https://github.com/maypok86/otter)
|
||||||
- [sessions](https://github.com/gorilla/sessions)
|
- [sessions](https://github.com/gorilla/sessions)
|
||||||
- [sqlite](https://sqlite.org/)
|
- [sqlite](https://sqlite.org/)
|
||||||
|
- [tailwindcss](https://github.com/tailwindlabs/tailwindcss)
|
||||||
- [testify](https://github.com/stretchr/testify)
|
- [testify](https://github.com/stretchr/testify)
|
||||||
- [validator](https://github.com/go-playground/validator)
|
- [validator](https://github.com/go-playground/validator)
|
||||||
- [viper](https://github.com/spf13/viper)
|
- [viper](https://github.com/spf13/viper)
|
||||||
|
|
@ -8,14 +8,6 @@ import (
|
||||||
"github.com/spf13/viper"
|
"github.com/spf13/viper"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
// StaticDir stores the name of the directory that will serve static files.
|
|
||||||
StaticDir = "static"
|
|
||||||
|
|
||||||
// StaticPrefix stores the URL prefix used when serving static files.
|
|
||||||
StaticPrefix = "files"
|
|
||||||
)
|
|
||||||
|
|
||||||
type environment string
|
type environment string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -25,8 +17,8 @@ const (
|
||||||
// EnvTest represents the test environment.
|
// EnvTest represents the test environment.
|
||||||
EnvTest environment = "test"
|
EnvTest environment = "test"
|
||||||
|
|
||||||
// EnvDevelop represents the development environment.
|
// EnvDevelopment represents the development environment.
|
||||||
EnvDevelop environment = "dev"
|
EnvDevelopment environment = "dev"
|
||||||
|
|
||||||
// EnvStaging represents the staging environment.
|
// EnvStaging represents the staging environment.
|
||||||
EnvStaging environment = "staging"
|
EnvStaging environment = "staging"
|
||||||
|
|
@ -92,7 +84,7 @@ type (
|
||||||
CacheConfig struct {
|
CacheConfig struct {
|
||||||
Capacity int
|
Capacity int
|
||||||
Expiration struct {
|
Expiration struct {
|
||||||
StaticFile time.Duration
|
PublicFile time.Duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ app:
|
||||||
cache:
|
cache:
|
||||||
capacity: 100000
|
capacity: 100000
|
||||||
expiration:
|
expiration:
|
||||||
staticFile: "4380h"
|
publicFile: "4380h"
|
||||||
|
|
||||||
database:
|
database:
|
||||||
driver: "sqlite3"
|
driver: "sqlite3"
|
||||||
|
|
|
||||||
|
|
@ -128,7 +128,7 @@ func (h *Admin) EntityAddSubmit(n *gen.Type) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
err := h.admin.Create(ctx, n.Name)
|
err := h.admin.Create(ctx, n.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Danger(ctx, err.Error())
|
msg.Error(ctx, err.Error())
|
||||||
return h.EntityAdd(n)(ctx)
|
return h.EntityAdd(n)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,7 +154,7 @@ func (h *Admin) EntityEditSubmit(n *gen.Type) echo.HandlerFunc {
|
||||||
id := ctx.Get(context.AdminEntityIDKey).(int)
|
id := ctx.Get(context.AdminEntityIDKey).(int)
|
||||||
err := h.admin.Update(ctx, n.Name, id)
|
err := h.admin.Update(ctx, n.Name, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Danger(ctx, err.Error())
|
msg.Error(ctx, err.Error())
|
||||||
return h.EntityEdit(n)(ctx)
|
return h.EntityEdit(n)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -178,7 +178,7 @@ func (h *Admin) EntityDeleteSubmit(n *gen.Type) echo.HandlerFunc {
|
||||||
return func(ctx echo.Context) error {
|
return func(ctx echo.Context) error {
|
||||||
id := ctx.Get(context.AdminEntityIDKey).(int)
|
id := ctx.Get(context.AdminEntityIDKey).(int)
|
||||||
if err := h.admin.Delete(ctx, n.Name, id); err != nil {
|
if err := h.admin.Delete(ctx, n.Name, id); err != nil {
|
||||||
msg.Danger(ctx, err.Error())
|
msg.Error(ctx, err.Error())
|
||||||
return h.EntityDelete(n)(ctx)
|
return h.EntityDelete(n)(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ func (h *Auth) LoginSubmit(ctx echo.Context) error {
|
||||||
authFailed := func() error {
|
authFailed := func() error {
|
||||||
input.SetFieldError("Email", "")
|
input.SetFieldError("Email", "")
|
||||||
input.SetFieldError("Password", "")
|
input.SetFieldError("Password", "")
|
||||||
msg.Danger(ctx, "Invalid credentials. Please try again.")
|
msg.Error(ctx, "Invalid credentials. Please try again.")
|
||||||
return h.LoginPage(ctx)
|
return h.LoginPage(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -185,7 +185,7 @@ func (h *Auth) Logout(ctx echo.Context) error {
|
||||||
if err := h.auth.Logout(ctx); err == nil {
|
if err := h.auth.Logout(ctx); err == nil {
|
||||||
msg.Success(ctx, "You have been logged out successfully.")
|
msg.Success(ctx, "You have been logged out successfully.")
|
||||||
} else {
|
} else {
|
||||||
msg.Danger(ctx, "An error occurred. Please try again.")
|
msg.Error(ctx, "An error occurred. Please try again.")
|
||||||
}
|
}
|
||||||
return redirect.New(ctx).
|
return redirect.New(ctx).
|
||||||
Route(routenames.Home).
|
Route(routenames.Home).
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ func (h *Files) Page(ctx echo.Context) error {
|
||||||
func (h *Files) Submit(ctx echo.Context) error {
|
func (h *Files) Submit(ctx echo.Context) error {
|
||||||
file, err := ctx.FormFile("file")
|
file, err := ctx.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
msg.Danger(ctx, "A file is required.")
|
msg.Error(ctx, "A file is required.")
|
||||||
return h.Page(ctx)
|
return h.Page(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ func (h *Pages) fetchPosts(pager *pager.Pager) []models.Post {
|
||||||
|
|
||||||
for k := range posts {
|
for k := range posts {
|
||||||
posts[k] = models.Post{
|
posts[k] = models.Post{
|
||||||
|
ID: k + 1,
|
||||||
Title: fmt.Sprintf("Post example #%d", k+1),
|
Title: fmt.Sprintf("Post example #%d", k+1),
|
||||||
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
|
Body: fmt.Sprintf("Lorem ipsum example #%d ddolor sit amet, consectetur adipiscing elit. Nam elementum vulputate tristique.", k+1),
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ func TestPages__About(t *testing.T) {
|
||||||
toDoc()
|
toDoc()
|
||||||
|
|
||||||
// Goquery is an excellent package to use for testing HTML markup
|
// Goquery is an excellent package to use for testing HTML markup
|
||||||
h1 := doc.Find("h1.title")
|
h1 := doc.Find("h1")
|
||||||
assert.Len(t, h1.Nodes, 1)
|
assert.Len(t, h1.Nodes, 1)
|
||||||
assert.Equal(t, "About", h1.Text())
|
assert.Equal(t, "About", h1.Text())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,31 +2,52 @@ package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
|
"github.com/labstack/echo/v4"
|
||||||
echomw "github.com/labstack/echo/v4/middleware"
|
echomw "github.com/labstack/echo/v4/middleware"
|
||||||
"github.com/mikestefanello/pagoda/config"
|
|
||||||
"github.com/mikestefanello/pagoda/pkg/context"
|
"github.com/mikestefanello/pagoda/pkg/context"
|
||||||
"github.com/mikestefanello/pagoda/pkg/middleware"
|
"github.com/mikestefanello/pagoda/pkg/middleware"
|
||||||
"github.com/mikestefanello/pagoda/pkg/services"
|
"github.com/mikestefanello/pagoda/pkg/services"
|
||||||
|
files "github.com/mikestefanello/pagoda/public"
|
||||||
)
|
)
|
||||||
|
|
||||||
// BuildRouter builds the router.
|
// BuildRouter builds the router.
|
||||||
func BuildRouter(c *services.Container) error {
|
func BuildRouter(c *services.Container) error {
|
||||||
// Static files with proper cache control.
|
// Force HTTPS, if enabled.
|
||||||
// ui.File() should be used in ui components to append a cache key to the URL in order to break cache
|
if c.Config.HTTP.TLS.Enabled {
|
||||||
// after each server restart.
|
c.Web.Use(echomw.HTTPSRedirect())
|
||||||
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.StaticFile)).
|
}
|
||||||
Static(config.StaticPrefix, config.StaticDir)
|
|
||||||
|
// Serve public files with cache control.
|
||||||
|
c.Web.Group("", middleware.CacheControl(c.Config.Cache.Expiration.PublicFile)).
|
||||||
|
Static("files", "public/files")
|
||||||
|
|
||||||
|
// Serve static files.
|
||||||
|
// ui.StaticFile() should be used in ui components to append a cache key to the URL to break cache
|
||||||
|
// after each server reboot.
|
||||||
|
c.Web.Group(
|
||||||
|
"",
|
||||||
|
echomw.GzipWithConfig(echomw.GzipConfig{
|
||||||
|
Skipper: func(c echo.Context) bool {
|
||||||
|
for _, ext := range []string{
|
||||||
|
".js",
|
||||||
|
".css",
|
||||||
|
} {
|
||||||
|
if strings.HasSuffix(c.Request().URL.Path, ext) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
middleware.CacheControl(c.Config.Cache.Expiration.PublicFile),
|
||||||
|
).StaticFS("static", echo.MustSubFS(files.Static, "static"))
|
||||||
|
|
||||||
// Non-static file route group.
|
// Non-static file route group.
|
||||||
g := c.Web.Group("")
|
g := c.Web.Group("")
|
||||||
|
|
||||||
// Force HTTPS, if enabled.
|
|
||||||
if c.Config.HTTP.TLS.Enabled {
|
|
||||||
g.Use(echomw.HTTPSRedirect())
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a cookie store for session data.
|
// Create a cookie store for session data.
|
||||||
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
|
cookieStore := sessions.NewCookieStore([]byte(c.Config.App.EncryptionKey))
|
||||||
cookieStore.Options.HttpOnly = true
|
cookieStore.Options.HttpOnly = true
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ const (
|
||||||
// TypeWarning represents a warning message type.
|
// TypeWarning represents a warning message type.
|
||||||
TypeWarning Type = "warning"
|
TypeWarning Type = "warning"
|
||||||
|
|
||||||
// TypeDanger represents a danger message type.
|
// TypeError represents an error message type.
|
||||||
TypeDanger Type = "danger"
|
TypeError Type = "error"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -44,9 +44,9 @@ func Warning(ctx echo.Context, message string) {
|
||||||
Set(ctx, TypeWarning, message)
|
Set(ctx, TypeWarning, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Danger sets a danger flash message.
|
// Error sets an error flash message.
|
||||||
func Danger(ctx echo.Context, message string) {
|
func Error(ctx echo.Context, message string) {
|
||||||
Set(ctx, TypeDanger, message)
|
Set(ctx, TypeError, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set adds a new flash message of a given type into the session storage.
|
// Set adds a new flash message of a given type into the session storage.
|
||||||
|
|
@ -61,19 +61,19 @@ func Set(ctx echo.Context, typ Type, message string) {
|
||||||
// Get gets flash messages of a given type from the session storage.
|
// Get gets flash messages of a given type from the session storage.
|
||||||
// Errors will be logged and not returned.
|
// Errors will be logged and not returned.
|
||||||
func Get(ctx echo.Context, typ Type) []string {
|
func Get(ctx echo.Context, typ Type) []string {
|
||||||
var msgs []string
|
|
||||||
|
|
||||||
if sess, err := getSession(ctx); err == nil {
|
if sess, err := getSession(ctx); err == nil {
|
||||||
if flash := sess.Flashes(string(typ)); len(flash) > 0 {
|
if flash := sess.Flashes(string(typ)); len(flash) > 0 {
|
||||||
save(ctx, sess)
|
save(ctx, sess)
|
||||||
|
|
||||||
|
msgs := make([]string, 0, len(flash))
|
||||||
for _, m := range flash {
|
for _, m := range flash {
|
||||||
msgs = append(msgs, m.(string))
|
msgs = append(msgs, m.(string))
|
||||||
}
|
}
|
||||||
|
return msgs
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return msgs
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSession gets the flash message session.
|
// getSession gets the flash message session.
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,8 @@ func TestMsg(t *testing.T) {
|
||||||
assertMsg(TypeInfo, text)
|
assertMsg(TypeInfo, text)
|
||||||
|
|
||||||
text = "ccc"
|
text = "ccc"
|
||||||
Danger(ctx, text)
|
Error(ctx, text)
|
||||||
assertMsg(TypeDanger, text)
|
assertMsg(TypeError, text)
|
||||||
|
|
||||||
text = "ddd"
|
text = "ddd"
|
||||||
Warning(ctx, text)
|
Warning(ctx, text)
|
||||||
|
|
|
||||||
|
|
@ -221,7 +221,7 @@ func (c *Container) initMail() {
|
||||||
// initTasks initializes the task client.
|
// initTasks initializes the task client.
|
||||||
func (c *Container) initTasks() {
|
func (c *Container) initTasks() {
|
||||||
var err error
|
var err error
|
||||||
// You could use a separate database for tasks, if you'd like. but using one
|
// You could use a separate database for tasks, if you'd like, but using one
|
||||||
// makes transaction support easier.
|
// makes transaction support easier.
|
||||||
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
|
c.Tasks, err = backlite.NewClient(backlite.ClientConfig{
|
||||||
DB: c.Database,
|
DB: c.Database,
|
||||||
|
|
|
||||||
|
|
@ -3,62 +3,64 @@ package components
|
||||||
import (
|
import (
|
||||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
|
"github.com/mikestefanello/pagoda/pkg/ui/icons"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
func FlashMessages(r *ui.Request) Node {
|
func FlashMessages(r *ui.Request) Node {
|
||||||
var g Group
|
var g Group
|
||||||
|
var color Color
|
||||||
|
|
||||||
for _, typ := range []msg.Type{
|
for _, typ := range []msg.Type{
|
||||||
msg.TypeSuccess,
|
msg.TypeSuccess,
|
||||||
msg.TypeInfo,
|
msg.TypeInfo,
|
||||||
msg.TypeWarning,
|
msg.TypeWarning,
|
||||||
msg.TypeDanger,
|
msg.TypeError,
|
||||||
} {
|
} {
|
||||||
for _, str := range msg.Get(r.Context, typ) {
|
for _, str := range msg.Get(r.Context, typ) {
|
||||||
g = append(g, Notification(typ, str))
|
switch typ {
|
||||||
|
case msg.TypeSuccess:
|
||||||
|
color = ColorSuccess
|
||||||
|
case msg.TypeInfo:
|
||||||
|
color = ColorInfo
|
||||||
|
case msg.TypeWarning:
|
||||||
|
color = ColorWarning
|
||||||
|
case msg.TypeError:
|
||||||
|
color = ColorError
|
||||||
|
}
|
||||||
|
|
||||||
|
g = append(g, Alert(color, str))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
func Notification(typ msg.Type, text string) Node {
|
func Alert(color Color, text string) Node {
|
||||||
var class string
|
var class string
|
||||||
|
|
||||||
switch typ {
|
switch color {
|
||||||
case msg.TypeSuccess:
|
case ColorSuccess:
|
||||||
class = "success"
|
class = "alert-success"
|
||||||
case msg.TypeInfo:
|
case ColorInfo:
|
||||||
class = "info"
|
class = "alert-info"
|
||||||
case msg.TypeWarning:
|
case ColorWarning:
|
||||||
class = "warning"
|
class = "alert-warning"
|
||||||
case msg.TypeDanger:
|
case ColorError:
|
||||||
class = "danger"
|
class = "alert-error"
|
||||||
}
|
}
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
Class("notification is-"+class),
|
Role("alert"),
|
||||||
|
Class("alert mb-2 "+class),
|
||||||
Attr("x-data", "{show: true}"),
|
Attr("x-data", "{show: true}"),
|
||||||
Attr("x-show", "show"),
|
Attr("x-show", "show"),
|
||||||
Button(
|
Span(
|
||||||
Class("delete"),
|
|
||||||
Attr("@click", "show = false"),
|
Attr("@click", "show = false"),
|
||||||
|
Class("cursor-pointer"),
|
||||||
|
icons.XCircle(),
|
||||||
),
|
),
|
||||||
Text(text),
|
Span(Text(text)),
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func Message(class, header string, body Node) Node {
|
|
||||||
return Article(
|
|
||||||
Class("message "+class),
|
|
||||||
If(header != "", Div(
|
|
||||||
Class("message-header"),
|
|
||||||
P(Text(header)),
|
|
||||||
)),
|
|
||||||
Div(
|
|
||||||
Class("message-body"),
|
|
||||||
body,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
121
pkg/ui/components/data.go
Normal file
121
pkg/ui/components/data.go
Normal file
|
|
@ -0,0 +1,121 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
import (
|
||||||
|
. "maragu.dev/gomponents"
|
||||||
|
. "maragu.dev/gomponents/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
CardParams struct {
|
||||||
|
Title string
|
||||||
|
Body Group
|
||||||
|
Footer Group
|
||||||
|
Color Color
|
||||||
|
Size Size
|
||||||
|
}
|
||||||
|
|
||||||
|
Stat struct {
|
||||||
|
Title string
|
||||||
|
Value string
|
||||||
|
Description string
|
||||||
|
Icon Node
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func Badge(color Color, text string) Node {
|
||||||
|
var class string
|
||||||
|
|
||||||
|
switch color {
|
||||||
|
case ColorSuccess:
|
||||||
|
class = "badge-success"
|
||||||
|
case ColorWarning:
|
||||||
|
class = "badge-warning"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Class("badge "+class),
|
||||||
|
Text(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Divider(text string) Node {
|
||||||
|
return Div(
|
||||||
|
Class("divider"),
|
||||||
|
Text(text),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Card(params CardParams) Node {
|
||||||
|
var colorClass, sizeClass string
|
||||||
|
|
||||||
|
switch params.Color {
|
||||||
|
case ColorSuccess:
|
||||||
|
colorClass = "bg-success text-success-content"
|
||||||
|
case ColorPrimary:
|
||||||
|
colorClass = "bg-primary text-primary-content"
|
||||||
|
case ColorAccent:
|
||||||
|
colorClass = "bg-accent text-accent-content"
|
||||||
|
case ColorNeutral:
|
||||||
|
colorClass = "bg-neutral text-neutral-content"
|
||||||
|
case ColorWarning:
|
||||||
|
colorClass = "bg-warning text-warning-content"
|
||||||
|
case ColorInfo:
|
||||||
|
colorClass = "bg-info text-info-content"
|
||||||
|
}
|
||||||
|
|
||||||
|
switch params.Size {
|
||||||
|
case SizeSmall:
|
||||||
|
sizeClass = "card-sm"
|
||||||
|
case SizeMedium:
|
||||||
|
sizeClass = "card-md"
|
||||||
|
case SizeLarge:
|
||||||
|
sizeClass = "card-lg"
|
||||||
|
}
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Class("cards mb-2 "+colorClass+" "+sizeClass),
|
||||||
|
Div(
|
||||||
|
Class("card-body"),
|
||||||
|
If(len(params.Title) > 0, Span(
|
||||||
|
Class("card-title"),
|
||||||
|
Text(params.Title),
|
||||||
|
)),
|
||||||
|
params.Body,
|
||||||
|
If(params.Footer != nil, Div(
|
||||||
|
Class("card-actions justify-end"),
|
||||||
|
params.Footer,
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Stats(stats ...Stat) Node {
|
||||||
|
g := make(Group, 0, len(stats))
|
||||||
|
for _, stat := range stats {
|
||||||
|
g = append(g, Div(
|
||||||
|
Class("stat"),
|
||||||
|
Iff(stat.Icon != nil, func() Node {
|
||||||
|
return Div(
|
||||||
|
Class("stat-figure text-secondary"),
|
||||||
|
stat.Icon,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
Div(
|
||||||
|
Class("stat-title"),
|
||||||
|
Text(stat.Title),
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Class("stat-value"),
|
||||||
|
Text(stat.Value),
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Class("stat-desc"),
|
||||||
|
Text(stat.Description),
|
||||||
|
),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return Div(
|
||||||
|
Class("stats shadow"),
|
||||||
|
g,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,12 @@ type (
|
||||||
Help string
|
Help string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
FileFieldParams struct {
|
||||||
|
Name string
|
||||||
|
Label string
|
||||||
|
Help string
|
||||||
|
}
|
||||||
|
|
||||||
OptionsParams struct {
|
OptionsParams struct {
|
||||||
Form form.Form
|
Form form.Form
|
||||||
FormField string
|
FormField string
|
||||||
|
|
@ -26,6 +32,7 @@ type (
|
||||||
Label string
|
Label string
|
||||||
Value string
|
Value string
|
||||||
Options []Choice
|
Options []Choice
|
||||||
|
Help string
|
||||||
}
|
}
|
||||||
|
|
||||||
Choice struct {
|
Choice struct {
|
||||||
|
|
@ -52,38 +59,22 @@ type (
|
||||||
)
|
)
|
||||||
|
|
||||||
func ControlGroup(controls ...Node) Node {
|
func ControlGroup(controls ...Node) Node {
|
||||||
g := make(Group, len(controls))
|
|
||||||
for i, control := range controls {
|
|
||||||
g[i] = Div(
|
|
||||||
Class("control"),
|
|
||||||
control,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
Class("field is-grouped"),
|
Class("mt-2 flex gap-2"),
|
||||||
g,
|
Group(controls),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TextareaField(el TextareaFieldParams) Node {
|
func TextareaField(el TextareaFieldParams) Node {
|
||||||
return Div(
|
return Fieldset(
|
||||||
Class("field"),
|
el.Label,
|
||||||
Label(
|
|
||||||
For("name"),
|
|
||||||
Class("label"),
|
|
||||||
Text(el.Label),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
Class("control"),
|
|
||||||
Textarea(
|
Textarea(
|
||||||
|
Class("textarea h-24 w-2/3 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||||
ID(el.Name),
|
ID(el.Name),
|
||||||
Name(el.Name),
|
Name(el.Name),
|
||||||
Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
|
|
||||||
Text(el.Value),
|
Text(el.Value),
|
||||||
),
|
),
|
||||||
),
|
Help(el.Help),
|
||||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
|
||||||
formFieldErrors(el.Form, el.FormField),
|
formFieldErrors(el.Form, el.FormField),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -91,25 +82,27 @@ func TextareaField(el TextareaFieldParams) Node {
|
||||||
func Radios(el OptionsParams) Node {
|
func Radios(el OptionsParams) Node {
|
||||||
buttons := make(Group, len(el.Options))
|
buttons := make(Group, len(el.Options))
|
||||||
for i, opt := range el.Options {
|
for i, opt := range el.Options {
|
||||||
buttons[i] = Label(
|
id := "radio-" + el.Name + "-" + opt.Value
|
||||||
Class("radio"),
|
buttons[i] = Div(
|
||||||
|
Class("mb-2"),
|
||||||
Input(
|
Input(
|
||||||
|
ID(id),
|
||||||
Type("radio"),
|
Type("radio"),
|
||||||
Name(el.Name),
|
Name(el.Name),
|
||||||
Value(opt.Value),
|
Value(opt.Value),
|
||||||
|
Class("radio mr-1 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||||
If(el.Value == opt.Value, Checked()),
|
If(el.Value == opt.Value, Checked()),
|
||||||
),
|
),
|
||||||
Text(" "+opt.Label),
|
Label(
|
||||||
|
Text(opt.Label),
|
||||||
|
For(id),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Div(
|
return Fieldset(
|
||||||
Class("control field"),
|
el.Label,
|
||||||
Label(Class("label"), Text(el.Label)),
|
|
||||||
Div(
|
|
||||||
Class("radios"),
|
|
||||||
buttons,
|
buttons,
|
||||||
),
|
|
||||||
formFieldErrors(el.Form, el.FormField),
|
formFieldErrors(el.Form, el.FormField),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -124,28 +117,23 @@ func SelectList(el OptionsParams) Node {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return Div(
|
return Fieldset(
|
||||||
Class("control field"),
|
el.Label,
|
||||||
Label(Class("label"), Text(el.Label)),
|
|
||||||
Div(
|
|
||||||
Class("select"),
|
|
||||||
Select(
|
Select(
|
||||||
Name(el.Name),
|
Class("select "+formFieldStatusClass(el.Form, el.FormField)),
|
||||||
buttons,
|
buttons,
|
||||||
),
|
),
|
||||||
),
|
Help(el.Help),
|
||||||
formFieldErrors(el.Form, el.FormField),
|
formFieldErrors(el.Form, el.FormField),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func Checkbox(el CheckboxParams) Node {
|
func Checkbox(el CheckboxParams) Node {
|
||||||
return Div(
|
return Div(
|
||||||
Class("field"),
|
|
||||||
Div(
|
|
||||||
Class("control"),
|
|
||||||
Label(
|
Label(
|
||||||
Class("checkbox"),
|
Class("label"),
|
||||||
Input(
|
Input(
|
||||||
|
Class("checkbox"),
|
||||||
Type("checkbox"),
|
Type("checkbox"),
|
||||||
Name(el.Name),
|
Name(el.Name),
|
||||||
If(el.Checked, Checked()),
|
If(el.Checked, Checked()),
|
||||||
|
|
@ -153,53 +141,53 @@ func Checkbox(el CheckboxParams) Node {
|
||||||
),
|
),
|
||||||
Text(" "+el.Label),
|
Text(" "+el.Label),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
formFieldErrors(el.Form, el.FormField),
|
formFieldErrors(el.Form, el.FormField),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func InputField(el InputFieldParams) Node {
|
func InputField(el InputFieldParams) Node {
|
||||||
return Div(
|
return Fieldset(
|
||||||
Class("field"),
|
el.Label,
|
||||||
Label(
|
|
||||||
Class("label"),
|
|
||||||
For(el.Name),
|
|
||||||
Text(el.Label),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
Class("control"),
|
|
||||||
Input(
|
Input(
|
||||||
ID(el.Name),
|
ID(el.Name),
|
||||||
Name(el.Name),
|
Name(el.Name),
|
||||||
Type(el.InputType),
|
Type(el.InputType),
|
||||||
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
|
||||||
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
||||||
Value(el.Value),
|
Value(el.Value),
|
||||||
|
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
||||||
),
|
),
|
||||||
),
|
Help(el.Help),
|
||||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
|
||||||
formFieldErrors(el.Form, el.FormField),
|
formFieldErrors(el.Form, el.FormField),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FileField(name, label string) Node {
|
func Help(text string) Node {
|
||||||
return Div(
|
return If(len(text) > 0, Div(
|
||||||
Class("field file"),
|
Class("label"),
|
||||||
Label(
|
Text(text),
|
||||||
Class("file-label"),
|
))
|
||||||
Input(
|
}
|
||||||
Class("file-input"),
|
|
||||||
Type("file"),
|
func Fieldset(label string, els ...Node) Node {
|
||||||
Name(name),
|
return FieldSet(
|
||||||
),
|
Class("fieldset"),
|
||||||
Span(
|
If(len(label) > 0, Legend(
|
||||||
Class("file-cta"),
|
Class("fieldset-legend"),
|
||||||
Span(
|
|
||||||
Class("file-label"),
|
|
||||||
Text(label),
|
Text(label),
|
||||||
|
)),
|
||||||
|
Group(els),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func FileField(el FileFieldParams) Node {
|
||||||
|
return Fieldset(
|
||||||
|
el.Label,
|
||||||
|
Input(
|
||||||
|
Type("file"),
|
||||||
|
Class("file-input"),
|
||||||
|
Name(el.Name),
|
||||||
),
|
),
|
||||||
),
|
Help(el.Help),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,9 +198,9 @@ func formFieldStatusClass(fm form.Form, formField string) string {
|
||||||
case !fm.IsSubmitted():
|
case !fm.IsSubmitted():
|
||||||
return ""
|
return ""
|
||||||
case fm.FieldHasErrors(formField):
|
case fm.FieldHasErrors(formField):
|
||||||
return "is-danger"
|
return "input-error"
|
||||||
default:
|
default:
|
||||||
return "is-success"
|
return "input-success"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,8 +216,8 @@ func formFieldErrors(fm form.Form, field string) Node {
|
||||||
|
|
||||||
g := make(Group, len(errs))
|
g := make(Group, len(errs))
|
||||||
for i, err := range errs {
|
for i, err := range errs {
|
||||||
g[i] = P(
|
g[i] = Div(
|
||||||
Class("help is-danger"),
|
Class("text-error"),
|
||||||
Text(err),
|
Text(err),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -245,17 +233,35 @@ func CSRF(r *ui.Request) Node {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func FormButton(class, label string) Node {
|
func FormButton(color Color, label string) Node {
|
||||||
return Button(
|
return Button(
|
||||||
Class("button "+class),
|
Class("btn "+buttonColor(color)),
|
||||||
Text(label),
|
Text(label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func ButtonLink(href, class, label string) Node {
|
func ButtonLink(color Color, href, label string) Node {
|
||||||
return A(
|
return A(
|
||||||
Href(href),
|
Href(href),
|
||||||
Class("button "+class),
|
Class("btn "+buttonColor(color)),
|
||||||
Text(label),
|
Text(label),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func buttonColor(color Color) string {
|
||||||
|
// Only colors being used are included so unused styles are not compiled.
|
||||||
|
switch color {
|
||||||
|
case ColorPrimary:
|
||||||
|
return "btn-primary"
|
||||||
|
case ColorInfo:
|
||||||
|
return "btn-info"
|
||||||
|
case ColorAccent:
|
||||||
|
return "btn-accent"
|
||||||
|
case ColorError:
|
||||||
|
return "btn-error"
|
||||||
|
case ColorLink:
|
||||||
|
return "btn-link"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,17 +8,18 @@ import (
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
func JS(r *ui.Request) Node {
|
func JS() Node {
|
||||||
return Group{
|
return Group{
|
||||||
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js")),
|
Script(Src("https://unpkg.com/htmx.org@2.0.0/dist/htmx.min.js"), Defer()),
|
||||||
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
|
Script(Src("https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"), Defer()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func CSS() Node {
|
func CSS() Node {
|
||||||
return Link(
|
return Link(
|
||||||
Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
|
Href(ui.StaticFile("main.css")),
|
||||||
Rel("stylesheet"),
|
Rel("stylesheet"),
|
||||||
|
Type("text/css"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ func Metatags(r *ui.Request) Node {
|
||||||
return Group{
|
return Group{
|
||||||
Meta(Charset("utf-8")),
|
Meta(Charset("utf-8")),
|
||||||
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
|
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
|
||||||
Link(Rel("icon"), Href(ui.File("favicon.png"))),
|
Link(Rel("icon"), Href(ui.StaticFile("favicon.png"))),
|
||||||
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
|
TitleEl(Text(r.Config.App.Name), If(r.Title != "", Text(" | "+r.Title))),
|
||||||
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
|
If(r.Metatags.Description != "", Meta(Name("description"), Content(r.Metatags.Description))),
|
||||||
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
|
If(len(r.Metatags.Keywords) > 0, Meta(Name("keywords"), Content(strings.Join(r.Metatags.Keywords, ", ")))),
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,72 @@
|
||||||
package components
|
package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
|
. "maragu.dev/gomponents/components"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
func MenuLink(r *ui.Request, title, routeName string, routeParams ...any) Node {
|
func MenuLink(r *ui.Request, icon Node, title, routeName string, routeParams ...any) Node {
|
||||||
href := r.Path(routeName, routeParams...)
|
href := r.Path(routeName, routeParams...)
|
||||||
|
|
||||||
return Li(
|
return Li(
|
||||||
|
Class("ml-2"),
|
||||||
A(
|
A(
|
||||||
Href(href),
|
Href(href),
|
||||||
|
icon,
|
||||||
Text(title),
|
Text(title),
|
||||||
If(href == r.CurrentPath, Class("is-active")),
|
Classes{
|
||||||
|
"menu-active": href == r.CurrentPath,
|
||||||
|
"p-2": true,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Pager(page int, path string, hasNext bool, hxTarget string) Node {
|
||||||
|
href := func(page int) string {
|
||||||
|
return fmt.Sprintf("%s?%s=%d",
|
||||||
|
path,
|
||||||
|
pager.QueryKey,
|
||||||
|
page,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Div(
|
||||||
|
Class("join"),
|
||||||
|
A(
|
||||||
|
Class("join-item btn"),
|
||||||
|
Text("«"),
|
||||||
|
If(page <= 1, Disabled()),
|
||||||
|
Href(href(page-1)),
|
||||||
|
Iff(len(hxTarget) > 0, func() Node {
|
||||||
|
return Group{
|
||||||
|
Attr("hx-get", href(page-1)),
|
||||||
|
Attr("hx-swap", "outerHTML"),
|
||||||
|
Attr("hx-target", hxTarget),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Button(
|
||||||
|
Class("join-item btn"),
|
||||||
|
Textf("Page %d", page),
|
||||||
|
),
|
||||||
|
A(
|
||||||
|
Class("join-item btn"),
|
||||||
|
Text("»"),
|
||||||
|
If(!hasNext, Disabled()),
|
||||||
|
Href(href(page+1)),
|
||||||
|
Iff(len(hxTarget) > 0, func() Node {
|
||||||
|
return Group{
|
||||||
|
Attr("hx-get", href(page+1)),
|
||||||
|
Attr("hx-swap", "outerHTML"),
|
||||||
|
Attr("hx-target", hxTarget),
|
||||||
|
}
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
27
pkg/ui/components/styles.go
Normal file
27
pkg/ui/components/styles.go
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
package components
|
||||||
|
|
||||||
|
type (
|
||||||
|
Color int
|
||||||
|
Size int
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ColorNone Color = iota
|
||||||
|
ColorNeutral
|
||||||
|
ColorPrimary
|
||||||
|
ColorSecondary
|
||||||
|
ColorAccent
|
||||||
|
ColorInfo
|
||||||
|
ColorSuccess
|
||||||
|
ColorWarning
|
||||||
|
ColorError
|
||||||
|
ColorLink
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SizeExtraSmall Size = iota
|
||||||
|
SizeSmall
|
||||||
|
SizeMedium
|
||||||
|
SizeLarge
|
||||||
|
SizeExtraLarge
|
||||||
|
)
|
||||||
|
|
@ -2,6 +2,7 @@ package components
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
|
|
@ -11,46 +12,27 @@ type Tab struct {
|
||||||
Title, Body string
|
Title, Body string
|
||||||
}
|
}
|
||||||
|
|
||||||
func Tabs(heading, description string, items []Tab) Node {
|
func Tabs(tabs []Tab) Node {
|
||||||
renderTitles := func() Node {
|
g := make(Group, 0, len(tabs)*2)
|
||||||
g := make(Group, len(items))
|
id := fmt.Sprintf("tabs-%d", rand.Int())
|
||||||
for i, item := range items {
|
|
||||||
g[i] = Li(
|
|
||||||
Attr(":class", fmt.Sprintf("{'is-active': tab === %d}", i)),
|
|
||||||
Attr("@click", fmt.Sprintf("tab = %d", i)),
|
|
||||||
A(Text(item.Title)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return g
|
|
||||||
}
|
|
||||||
|
|
||||||
renderBodies := func() Node {
|
for i, tab := range tabs {
|
||||||
g := make(Group, len(items))
|
g = append(g,
|
||||||
for i, item := range items {
|
Input(
|
||||||
g[i] = Div(
|
Type("radio"),
|
||||||
Attr("x-show", fmt.Sprintf("tab == %d", i)),
|
Name(id),
|
||||||
P(Raw(" "+item.Body)),
|
Class("tab"),
|
||||||
)
|
Aria("label", tab.Title),
|
||||||
}
|
If(i == 0, Checked()),
|
||||||
return g
|
),
|
||||||
|
Div(
|
||||||
|
Class("tab-content bg-base-100 border-base-300 p-6"),
|
||||||
|
Raw(tab.Body),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
P(
|
Class("tabs tabs-lift"),
|
||||||
Class("subtitle mt-5"),
|
g,
|
||||||
Text(heading),
|
|
||||||
),
|
|
||||||
P(
|
|
||||||
Class("mb-4"),
|
|
||||||
Text(description),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
Attr("x-data", "{tab: 0}"),
|
|
||||||
Div(
|
|
||||||
Class("tabs"),
|
|
||||||
Ul(renderTitles()),
|
|
||||||
),
|
|
||||||
renderBodies(),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -113,10 +113,10 @@ func AdminEntity(r *ui.Request, schema *load.Schema, values url.Values) Node {
|
||||||
Method(http.MethodPost),
|
Method(http.MethodPost),
|
||||||
nodes,
|
nodes,
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-primary", "Submit"),
|
FormButton(ColorPrimary, "Submit"),
|
||||||
ButtonLink(
|
ButtonLink(
|
||||||
|
ColorNone,
|
||||||
r.Path(routenames.AdminEntityList(schema.Name)),
|
r.Path(routenames.AdminEntityList(schema.Name)),
|
||||||
"is-secondary",
|
|
||||||
"Cancel",
|
"Cancel",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -14,14 +14,13 @@ func AdminEntityDelete(r *ui.Request, entityTypeName string) Node {
|
||||||
return Form(
|
return Form(
|
||||||
Method(http.MethodPost),
|
Method(http.MethodPost),
|
||||||
P(
|
P(
|
||||||
Class("subtitle"),
|
|
||||||
Textf("Are you sure you want to delete this %s?", entityTypeName),
|
Textf("Are you sure you want to delete this %s?", entityTypeName),
|
||||||
),
|
),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Delete"),
|
FormButton(ColorError, "Delete"),
|
||||||
ButtonLink(
|
ButtonLink(
|
||||||
|
ColorNone,
|
||||||
r.Path(routenames.AdminEntityList(entityTypeName)),
|
r.Path(routenames.AdminEntityList(entityTypeName)),
|
||||||
"is-secondary",
|
|
||||||
"Cancel",
|
"Cancel",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -22,21 +22,22 @@ func (f *Cache) Render(r *ui.Request) Node {
|
||||||
ID("cache"),
|
ID("cache"),
|
||||||
Method(http.MethodPost),
|
Method(http.MethodPost),
|
||||||
Attr("hx-post", r.Path(routenames.CacheSubmit)),
|
Attr("hx-post", r.Path(routenames.CacheSubmit)),
|
||||||
Message(
|
Card(CardParams{
|
||||||
"is-info",
|
Title: "Test the cache",
|
||||||
"Test the cache",
|
Body: Group{
|
||||||
Group{
|
Span(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
|
||||||
P(Text("This route handler shows how the default in-memory cache works. Try updating the value using the form below and see how it persists after you reload the page.")),
|
Span(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
|
||||||
P(Text("HTMX makes it easy to re-render the cached value after the form is submitted.")),
|
|
||||||
},
|
},
|
||||||
),
|
Color: ColorInfo,
|
||||||
|
Size: SizeMedium,
|
||||||
|
}),
|
||||||
Label(
|
Label(
|
||||||
For("value"),
|
For("value"),
|
||||||
Class("value"),
|
Class("value"),
|
||||||
Text("Value in cache: "),
|
Text("Value in cache: "),
|
||||||
),
|
),
|
||||||
If(f.CurrentValue != "", Span(Class("tag is-success"), Text(f.CurrentValue))),
|
If(f.CurrentValue != "", Badge(ColorSuccess, f.CurrentValue)),
|
||||||
If(f.CurrentValue == "", I(Text("(empty)"))),
|
If(f.CurrentValue == "", Badge(ColorWarning, "empty")),
|
||||||
InputField(InputFieldParams{
|
InputField(InputFieldParams{
|
||||||
Form: f,
|
Form: f,
|
||||||
FormField: "Value",
|
FormField: "Value",
|
||||||
|
|
@ -46,7 +47,7 @@ func (f *Cache) Render(r *ui.Request) Node {
|
||||||
Value: f.Value,
|
Value: f.Value,
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Update cache"),
|
FormButton(ColorPrimary, "Update cache"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ func (f *Contact) Render(r *ui.Request) Node {
|
||||||
Value: f.Message,
|
Value: f.Message,
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Submit"),
|
FormButton(ColorPrimary, "Submit"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,13 @@ func (f File) Render(r *ui.Request) Node {
|
||||||
Method(http.MethodPost),
|
Method(http.MethodPost),
|
||||||
Action(r.Path(routenames.FilesSubmit)),
|
Action(r.Path(routenames.FilesSubmit)),
|
||||||
EncType("multipart/form-data"),
|
EncType("multipart/form-data"),
|
||||||
FileField("file", "Choose a file.. "),
|
FileField(FileFieldParams{
|
||||||
|
Name: "file",
|
||||||
|
Label: "Test file",
|
||||||
|
Help: "Pick a file to upload.",
|
||||||
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Upload"),
|
FormButton(ColorPrimary, "Upload"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ func (f *ForgotPassword) Render(r *ui.Request) Node {
|
||||||
Value: f.Email,
|
Value: f.Email,
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-primary", "Reset password"),
|
FormButton(ColorPrimary, "Reset password"),
|
||||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -40,10 +40,25 @@ func (f *Login) Render(r *ui.Request) Node {
|
||||||
Label: "Password",
|
Label: "Password",
|
||||||
Placeholder: "******",
|
Placeholder: "******",
|
||||||
}),
|
}),
|
||||||
|
Div(
|
||||||
|
Class("text-right text-primary mt-2"),
|
||||||
|
A(
|
||||||
|
Href(r.Path(routenames.ForgotPassword)),
|
||||||
|
Text("Forgot password?"),
|
||||||
|
),
|
||||||
|
),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Login"),
|
FormButton(ColorPrimary, "Login"),
|
||||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
|
Div(
|
||||||
|
Class("text-center text-base-content/50 mt-4"),
|
||||||
|
Text("Don't have an account? "),
|
||||||
|
A(
|
||||||
|
Href(r.Path(routenames.Register)),
|
||||||
|
Text("Register"),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,16 +51,24 @@ func (f *Register) Render(r *ui.Request) Node {
|
||||||
}),
|
}),
|
||||||
InputField(InputFieldParams{
|
InputField(InputFieldParams{
|
||||||
Form: f,
|
Form: f,
|
||||||
FormField: "PasswordConfirm",
|
FormField: "ConfirmPassword",
|
||||||
Name: "password-confirm",
|
Name: "password-confirm",
|
||||||
InputType: "password",
|
InputType: "password",
|
||||||
Label: "Confirm password",
|
Label: "Confirm password",
|
||||||
Placeholder: "******",
|
Placeholder: "******",
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-primary", "Register"),
|
FormButton(ColorPrimary, "Register"),
|
||||||
ButtonLink(r.Path(routenames.Home), "is-light", "Cancel"),
|
ButtonLink(ColorLink, r.Path(routenames.Home), "Cancel"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
|
Div(
|
||||||
|
Class("text-center text-base-content/50 mt-4"),
|
||||||
|
Text("Already have an account? "),
|
||||||
|
A(
|
||||||
|
Href(r.Path(routenames.Login)),
|
||||||
|
Text("Login"),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,7 @@ func (f *ResetPassword) Render(r *ui.Request) Node {
|
||||||
Placeholder: "******",
|
Placeholder: "******",
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-primary", "Update password"),
|
FormButton(ColorPrimary, "Update password"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ func (f *Task) Render(r *ui.Request) Node {
|
||||||
Help: "The message the task will output to the log",
|
Help: "The message the task will output to the log",
|
||||||
}),
|
}),
|
||||||
ControlGroup(
|
ControlGroup(
|
||||||
FormButton("is-link", "Add task to queue"),
|
FormButton(ColorPrimary, "Add task to queue"),
|
||||||
),
|
),
|
||||||
CSRF(r),
|
CSRF(r),
|
||||||
)
|
)
|
||||||
|
|
|
||||||
208
pkg/ui/icons/icons.go
Normal file
208
pkg/ui/icons/icons.go
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
package icons
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
||||||
|
. "maragu.dev/gomponents"
|
||||||
|
. "maragu.dev/gomponents/html"
|
||||||
|
)
|
||||||
|
|
||||||
|
func CircleStack() Node {
|
||||||
|
return icon("CircleStack",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 0v3.75m-16.5-3.75v3.75m16.5 0v3.75C20.25 16.153 16.556 18 12 18s-8.25-1.847-8.25-4.125v-3.75m16.5 0c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Eyes() Node {
|
||||||
|
return icon("Eyes",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z"),
|
||||||
|
),
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserCircle() Node {
|
||||||
|
return icon("UserCircle",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M17.982 18.725A7.488 7.488 0 0 0 12 15.75a7.488 7.488 0 0 0-5.982 2.975m11.963 0a9 9 0 1 0-11.963 0m11.963 0A8.966 8.966 0 0 1 12 21a8.966 8.966 0 0 1-5.982-2.275M15 9.75a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Globe() Node {
|
||||||
|
return icon("Globe",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Home() Node {
|
||||||
|
return icon("Home",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Info() Node {
|
||||||
|
return icon("Info",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Mail() Node {
|
||||||
|
return icon("Mail",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Archive() Node {
|
||||||
|
return icon("Archive",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m20.25 7.5-.625 10.632a2.25 2.25 0 0 1-2.247 2.118H6.622a2.25 2.25 0 0 1-2.247-2.118L3.75 7.5M10 11.25h4M3.375 7.5h17.25c.621 0 1.125-.504 1.125-1.125v-1.5c0-.621-.504-1.125-1.125-1.125H3.375c-.621 0-1.125.504-1.125 1.125v1.5c0 .621.504 1.125 1.125 1.125Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func PencilSquare() Node {
|
||||||
|
return icon("PencilSquare",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Document() Node {
|
||||||
|
return icon("Document",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Exit() Node {
|
||||||
|
return icon("Exit",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M8.25 9V5.25A2.25 2.25 0 0 1 10.5 3h6a2.25 2.25 0 0 1 2.25 2.25v13.5A2.25 2.25 0 0 1 16.5 21h-6a2.25 2.25 0 0 1-2.25-2.25V15m-3 0-3-3m0 0 3-3m-3 3H15"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enter() Node {
|
||||||
|
return icon("Enter",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserPlus() Node {
|
||||||
|
return icon("UserPlus",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M18 7.5v3m0 0v3m0-3h3m-3 0h-3m-2.25-4.125a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0ZM3 19.235v-.11a6.375 6.375 0 0 1 12.75 0v.109A12.318 12.318 0 0 1 9.374 21c-2.331 0-4.512-.645-6.374-1.766Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func QuestionCircle() Node {
|
||||||
|
return icon("QuestionCircle",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M9.879 7.519c1.171-1.025 3.071-1.025 4.242 0 1.172 1.025 1.172 2.687 0 3.712-.203.179-.43.326-.67.442-.745.361-1.45.999-1.45 1.827v.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 5.25h.008v.008H12v-.008Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func XCircle() Node {
|
||||||
|
return icon("XCircle",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func MagnifyingGlass() Node {
|
||||||
|
return icon("MagnifyingGlass",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func LockClosed() Node {
|
||||||
|
return icon("LockClosed",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func Star() Node {
|
||||||
|
return icon("Star",
|
||||||
|
El("path",
|
||||||
|
Attr("stroke-linecap", "round"),
|
||||||
|
Attr("stroke-linejoin", "round"),
|
||||||
|
Attr("d", "M11.48 3.499a.562.562 0 0 1 1.04 0l2.125 5.111a.563.563 0 0 0 .475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 0 0-.182.557l1.285 5.385a.562.562 0 0 1-.84.61l-4.725-2.885a.562.562 0 0 0-.586 0L6.982 20.54a.562.562 0 0 1-.84-.61l1.285-5.386a.562.562 0 0 0-.182-.557l-4.204-3.602a.562.562 0 0 1 .321-.988l5.518-.442a.563.563 0 0 0 .475-.345L11.48 3.5Z"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func icon(id string, els ...Node) Node {
|
||||||
|
return cache.SetIfNotExists(fmt.Sprintf("icon.%s", id), func() Node {
|
||||||
|
return SVG(
|
||||||
|
Attr("xmlns", "http://www.w3.org/2000/svg"),
|
||||||
|
Attr("fill", "none"),
|
||||||
|
Attr("viewBox", "0 0 24 24"),
|
||||||
|
Attr("stroke-width", "1.5"),
|
||||||
|
Attr("stroke", "currentColor"),
|
||||||
|
Class("w-5 h-5"),
|
||||||
|
Group(els),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,7 @@
|
||||||
package layouts
|
package layouts
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
|
||||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
|
|
@ -13,31 +11,24 @@ func Auth(r *ui.Request, content Node) Node {
|
||||||
return Doctype(
|
return Doctype(
|
||||||
HTML(
|
HTML(
|
||||||
Lang("en"),
|
Lang("en"),
|
||||||
Data("theme", "light"),
|
Data("theme", "dark"),
|
||||||
Head(
|
Head(
|
||||||
Metatags(r),
|
Metatags(r),
|
||||||
CSS(),
|
CSS(),
|
||||||
JS(r),
|
JS(),
|
||||||
),
|
),
|
||||||
Body(
|
Body(
|
||||||
Section(
|
|
||||||
Class("hero is-fullheight"),
|
|
||||||
Div(
|
Div(
|
||||||
Class("hero-body"),
|
Class("hero flex items-center justify-center min-h-screen"),
|
||||||
Div(
|
Div(
|
||||||
Class("container"),
|
Class("flex-col hero-content"),
|
||||||
Div(
|
Div(
|
||||||
Class("columns is-centered"),
|
Class("card shadow-md bg-base-200 w-96"),
|
||||||
Div(
|
Div(
|
||||||
Class("column is-half"),
|
Class("card-body"),
|
||||||
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
|
If(len(r.Title) > 0, H1(Class("text-2xl font-bold"), Text(r.Title))),
|
||||||
Div(
|
|
||||||
Class("notification"),
|
|
||||||
FlashMessages(r),
|
FlashMessages(r),
|
||||||
content,
|
content,
|
||||||
authNavBar(r),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -47,20 +38,3 @@ func Auth(r *ui.Request, content Node) Node {
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func authNavBar(r *ui.Request) Node {
|
|
||||||
return cache.SetIfNotExists("authNavBar", func() Node {
|
|
||||||
return Nav(
|
|
||||||
Class("navbar"),
|
|
||||||
Div(
|
|
||||||
Class("navbar-menu"),
|
|
||||||
Div(
|
|
||||||
Class("navbar-start"),
|
|
||||||
A(Class("navbar-item"), Href(r.Path(routenames.Login)), Text("Login")),
|
|
||||||
A(Class("navbar-item"), Href(r.Path(routenames.Register)), Text("Create an account")),
|
|
||||||
A(Class("navbar-item"), Href(r.Path(routenames.ForgotPassword)), Text("Forgot password")),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
"github.com/mikestefanello/pagoda/pkg/ui/cache"
|
||||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
|
"github.com/mikestefanello/pagoda/pkg/ui/icons"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
@ -14,118 +15,97 @@ func Primary(r *ui.Request, content Node) Node {
|
||||||
return Doctype(
|
return Doctype(
|
||||||
HTML(
|
HTML(
|
||||||
Lang("en"),
|
Lang("en"),
|
||||||
Data("theme", "light"),
|
Data("theme", "dark"),
|
||||||
Head(
|
Head(
|
||||||
Metatags(r),
|
Metatags(r),
|
||||||
CSS(),
|
CSS(),
|
||||||
JS(r),
|
JS(),
|
||||||
),
|
),
|
||||||
Body(
|
Body(
|
||||||
headerNavBar(r),
|
|
||||||
Div(
|
Div(
|
||||||
Class("container mt-5"),
|
Class("drawer lg:drawer-open"),
|
||||||
Div(
|
Input(
|
||||||
Class("columns"),
|
ID("sidebar"),
|
||||||
Div(
|
Type("checkbox"),
|
||||||
Class("column is-2"),
|
Class("drawer-toggle"),
|
||||||
sidebarMenu(r),
|
|
||||||
),
|
),
|
||||||
Div(
|
Div(
|
||||||
Class("column is-10"),
|
Class("drawer-content flex flex-col p-7 prose-base"),
|
||||||
Div(
|
If(len(r.Title) > 0, H1(Text(r.Title))),
|
||||||
Class("box"),
|
|
||||||
If(len(r.Title) > 0, H1(Class("title"), Text(r.Title))),
|
|
||||||
FlashMessages(r),
|
FlashMessages(r),
|
||||||
content,
|
content,
|
||||||
|
Label(
|
||||||
|
For("sidebar"),
|
||||||
|
Class("btn btn-primary drawer-button lg:hidden"),
|
||||||
|
Text("Open drawer"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
sidebarMenu(r),
|
||||||
),
|
),
|
||||||
),
|
searchModal(r),
|
||||||
HtmxListeners(r),
|
HtmxListeners(r),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func headerNavBar(r *ui.Request) Node {
|
func search() Node {
|
||||||
return cache.SetIfNotExists("layout.headerNavBar", func() Node {
|
return cache.SetIfNotExists("layout.search", func() Node {
|
||||||
return Nav(
|
return Div(
|
||||||
Class("navbar is-dark"),
|
Class("ml-2"),
|
||||||
Div(
|
Attr("x-data", ""),
|
||||||
Class("container"),
|
Label(
|
||||||
Div(
|
Class("input"),
|
||||||
Class("navbar-brand"),
|
icons.MagnifyingGlass(),
|
||||||
HxBoost(),
|
Input(
|
||||||
A(
|
Type("search"),
|
||||||
Href(r.Path(routenames.Home)),
|
Class("grow"),
|
||||||
Class("navbar-item"),
|
Placeholder("Search"),
|
||||||
Text("Pagoda"),
|
Attr("@click", "search_modal.showModal();"),
|
||||||
),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
ID("navbarMenu"),
|
|
||||||
Class("navbar-menu"),
|
|
||||||
Div(
|
|
||||||
Class("navbar-end"),
|
|
||||||
search(r),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func search(r *ui.Request) Node {
|
func searchModal(r *ui.Request) Node {
|
||||||
return cache.SetIfNotExists("layout.search", func() Node {
|
return cache.SetIfNotExists("layout.searchModal", func() Node {
|
||||||
return Div(
|
return Dialog(
|
||||||
Class("search mr-2 mt-1"),
|
ID("search_modal"),
|
||||||
Attr("x-data", "{modal:false}"),
|
|
||||||
Input(
|
|
||||||
Class("input"),
|
|
||||||
Type("search"),
|
|
||||||
Placeholder("Search..."),
|
|
||||||
Attr("@click", "modal = true; $nextTick(() => $refs.input.focus());"),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
Class("modal"),
|
Class("modal"),
|
||||||
Attr(":class", "modal ? 'is-active' : ''"),
|
|
||||||
Attr("x-show", "modal == true"),
|
|
||||||
Div(
|
Div(
|
||||||
Class("modal-background"),
|
Class("modal-box"),
|
||||||
|
Form(
|
||||||
|
Method("dialog"),
|
||||||
|
Button(
|
||||||
|
Class("btn btn-sm btn-circle btn-ghost absolute right-2 top-2"),
|
||||||
|
Text("✕"),
|
||||||
),
|
),
|
||||||
Div(
|
),
|
||||||
Class("modal-content"),
|
H3(
|
||||||
Attr("@click.outside", "modal = false;"),
|
Class("text-lg font-bold mb-2"),
|
||||||
Div(
|
|
||||||
Class("box"),
|
|
||||||
H2(
|
|
||||||
Class("subtitle"),
|
|
||||||
Text("Search"),
|
Text("Search"),
|
||||||
),
|
),
|
||||||
P(
|
|
||||||
Class("control"),
|
|
||||||
Input(
|
Input(
|
||||||
Attr("hx-get", r.Path(routenames.Search)),
|
Attr("hx-get", r.Path(routenames.Search)),
|
||||||
Attr("hx-trigger", "keyup changed delay:500ms"),
|
Attr("hx-trigger", "keyup changed delay:500ms"),
|
||||||
Attr("hx-target", "#results"),
|
Attr("hx-target", "#results"),
|
||||||
Name("query"),
|
Name("query"),
|
||||||
Class("input"),
|
Class("input w-full"),
|
||||||
Type("search"),
|
Type("search"),
|
||||||
Placeholder("Search..."),
|
Placeholder("Search..."),
|
||||||
Attr("x-ref", "input"),
|
|
||||||
),
|
),
|
||||||
),
|
Ul(
|
||||||
Div(
|
|
||||||
Class("block"),
|
|
||||||
),
|
|
||||||
Div(
|
|
||||||
ID("results"),
|
ID("results"),
|
||||||
|
Class("list"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
Form(
|
||||||
|
Method("dialog"),
|
||||||
|
Class("modal-backdrop"),
|
||||||
Button(
|
Button(
|
||||||
Class("modal-close is-large"),
|
Text("close"),
|
||||||
Aria("label", "close"),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -133,66 +113,67 @@ func search(r *ui.Request) Node {
|
||||||
}
|
}
|
||||||
|
|
||||||
func sidebarMenu(r *ui.Request) Node {
|
func sidebarMenu(r *ui.Request) Node {
|
||||||
|
header := func(text string) Node {
|
||||||
|
return Li(
|
||||||
|
Class("menu-title mt-3 uppercase"),
|
||||||
|
Span(Text(text)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
adminSubMenu := func() Node {
|
adminSubMenu := func() Node {
|
||||||
entityTypeNames := admin.GetEntityTypeNames()
|
entityTypeNames := admin.GetEntityTypeNames()
|
||||||
entityTypeLinks := make(Group, len(entityTypeNames))
|
entityTypeLinks := make(Group, len(entityTypeNames))
|
||||||
for _, n := range entityTypeNames {
|
for _, n := range entityTypeNames {
|
||||||
entityTypeLinks = append(entityTypeLinks, MenuLink(r, n, routenames.AdminEntityList(n)))
|
entityTypeLinks = append(entityTypeLinks, MenuLink(r, icons.PencilSquare(), n, routenames.AdminEntityList(n)))
|
||||||
}
|
}
|
||||||
|
|
||||||
return Group{
|
return Group{
|
||||||
P(
|
header("Entities"),
|
||||||
Class("menu-label"),
|
|
||||||
Text("Entities"),
|
|
||||||
),
|
|
||||||
Ul(
|
|
||||||
Class("menu-list"),
|
|
||||||
entityTypeLinks,
|
entityTypeLinks,
|
||||||
),
|
header("Monitoring"),
|
||||||
P(
|
|
||||||
Class("menu-label"),
|
|
||||||
Text("Monitoring"),
|
|
||||||
),
|
|
||||||
Ul(
|
|
||||||
Class("menu-list"),
|
|
||||||
Li(
|
Li(
|
||||||
A(
|
A(
|
||||||
|
icons.CircleStack(),
|
||||||
Href(r.Path(routenames.AdminTasks)),
|
Href(r.Path(routenames.AdminTasks)),
|
||||||
Text("Tasks"),
|
Text("Tasks"),
|
||||||
Target("_blank"),
|
Target("_blank"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Aside(
|
return Div(
|
||||||
Class("menu"),
|
Class("drawer-side"),
|
||||||
|
Label(
|
||||||
|
For("sidebar"),
|
||||||
|
Aria("label", "close sidebar"),
|
||||||
|
Class("drawer-overlay"),
|
||||||
|
),
|
||||||
|
Div(
|
||||||
|
Class("menu bg-base-200 text-base-content min-h-full w-80 p-4"),
|
||||||
|
Div(
|
||||||
|
Class("w-2/3 mx-auto mt-3 mb-10"),
|
||||||
|
Img(
|
||||||
|
Src(ui.StaticFile("logo.png")),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
search(),
|
||||||
|
Ul(
|
||||||
HxBoost(),
|
HxBoost(),
|
||||||
P(
|
header("General"),
|
||||||
Class("menu-label"),
|
MenuLink(r, icons.Home(), "Dashboard", routenames.Home),
|
||||||
Text("General"),
|
MenuLink(r, icons.Info(), "About", routenames.About),
|
||||||
),
|
MenuLink(r, icons.Mail(), "Contact", routenames.Contact),
|
||||||
Ul(
|
MenuLink(r, icons.Archive(), "Cache", routenames.Cache),
|
||||||
Class("menu-list"),
|
MenuLink(r, icons.CircleStack(), "Task", routenames.Task),
|
||||||
MenuLink(r, "Dashboard", routenames.Home),
|
MenuLink(r, icons.Document(), "Files", routenames.Files),
|
||||||
MenuLink(r, "About", routenames.About),
|
header("Account"),
|
||||||
MenuLink(r, "Contact", routenames.Contact),
|
If(r.IsAuth, MenuLink(r, icons.Exit(), "Logout", routenames.Logout)),
|
||||||
MenuLink(r, "Cache", routenames.Cache),
|
If(!r.IsAuth, MenuLink(r, icons.Enter(), "Login", routenames.Login)),
|
||||||
MenuLink(r, "Task", routenames.Task),
|
If(!r.IsAuth, MenuLink(r, icons.UserPlus(), "Register", routenames.Register)),
|
||||||
MenuLink(r, "Files", routenames.Files),
|
If(!r.IsAuth, MenuLink(r, icons.QuestionCircle(), "Forgot password", routenames.ForgotPasswordSubmit)),
|
||||||
),
|
|
||||||
P(
|
|
||||||
Class("menu-label"),
|
|
||||||
Text("Account"),
|
|
||||||
),
|
|
||||||
Ul(
|
|
||||||
Class("menu-list"),
|
|
||||||
If(r.IsAuth, MenuLink(r, "Logout", routenames.Logout)),
|
|
||||||
If(!r.IsAuth, MenuLink(r, "Login", routenames.Login)),
|
|
||||||
If(!r.IsAuth, MenuLink(r, "Register", routenames.Register)),
|
|
||||||
If(!r.IsAuth, MenuLink(r, "Forgot password", routenames.ForgotPasswordSubmit)),
|
|
||||||
),
|
|
||||||
Iff(r.IsAdmin, adminSubMenu),
|
Iff(r.IsAdmin, adminSubMenu),
|
||||||
|
),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
@ -16,6 +17,7 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
Post struct {
|
Post struct {
|
||||||
|
ID int
|
||||||
Title, Body string
|
Title, Body string
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -28,58 +30,38 @@ func (p *Posts) Render(path string) Node {
|
||||||
|
|
||||||
return Div(
|
return Div(
|
||||||
ID("posts"),
|
ID("posts"),
|
||||||
|
Ul(
|
||||||
|
Class("list bg-base-100 rounded-box shadow-md not-prose"),
|
||||||
g,
|
g,
|
||||||
Div(
|
|
||||||
Class("field is-grouped is-grouped-centered"),
|
|
||||||
If(!p.Pager.IsBeginning(), P(
|
|
||||||
Class("control"),
|
|
||||||
Button(
|
|
||||||
Class("button is-primary"),
|
|
||||||
Attr("hx-swap", "outerHTML"),
|
|
||||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page-1)),
|
|
||||||
Attr("hx-target", "#posts"),
|
|
||||||
Text("Previous page"),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
If(!p.Pager.IsEnd(), P(
|
|
||||||
Class("control"),
|
|
||||||
Button(
|
|
||||||
Class("button is-primary"),
|
|
||||||
Attr("hx-swap", "outerHTML"),
|
|
||||||
Attr("hx-get", fmt.Sprintf("%s?%s=%d", path, pager.QueryKey, p.Pager.Page+1)),
|
|
||||||
Attr("hx-target", "#posts"),
|
|
||||||
Text("Next page"),
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
),
|
),
|
||||||
|
Div(Class("mb-4")),
|
||||||
|
Pager(p.Pager.Page, path, !p.Pager.IsEnd(), "#posts"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *Post) Render() Node {
|
func (p *Post) Render() Node {
|
||||||
return Article(
|
return Li(
|
||||||
Class("media"),
|
Class("list-row"),
|
||||||
Figure(
|
Div(
|
||||||
Class("media-left"),
|
Class("text-4xl font-thin opacity-30 tabular-nums"),
|
||||||
P(
|
Text(fmt.Sprintf("%02d", p.ID)),
|
||||||
Class("image is-64x64"),
|
),
|
||||||
|
Div(
|
||||||
Img(
|
Img(
|
||||||
Src(ui.File("gopher.png")),
|
Class("size-10 rounded-box"),
|
||||||
|
Src(ui.StaticFile("gopher.png")),
|
||||||
Alt("Gopher"),
|
Alt("Gopher"),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
Div(
|
Div(
|
||||||
Class("media-content"),
|
Class("list-col-grow"),
|
||||||
Div(
|
Div(
|
||||||
Class("content"),
|
|
||||||
P(
|
|
||||||
Strong(
|
|
||||||
Text(p.Title),
|
Text(p.Title),
|
||||||
),
|
),
|
||||||
Br(),
|
Div(
|
||||||
|
Class("text-xs font-semibold opacity-60"),
|
||||||
Text(p.Body),
|
Text(p.Body),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,11 @@ type SearchResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *SearchResult) Render() Node {
|
func (s *SearchResult) Render() Node {
|
||||||
return A(
|
return Li(
|
||||||
Class("panel-block"),
|
Class("list-row"),
|
||||||
|
A(
|
||||||
Href(s.URL),
|
Href(s.URL),
|
||||||
Text(s.Title),
|
Text(s.Title),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,12 @@ func About(ctx echo.Context) error {
|
||||||
r.Title = "About"
|
r.Title = "About"
|
||||||
r.Metatags.Description = "Learn a little about what's included in Pagoda."
|
r.Metatags.Description = "Learn a little about what's included in Pagoda."
|
||||||
|
|
||||||
// The tabs are static so we can render and cache them.
|
// The tabs are static, so we can render and cache them.
|
||||||
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
|
tabs := cache.SetIfNotExists("pages.about.Tabs", func() Node {
|
||||||
return Group{
|
return Group{
|
||||||
|
H2(Text("Frontend")),
|
||||||
|
P(Text("The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.")),
|
||||||
Tabs(
|
Tabs(
|
||||||
"Frontend",
|
|
||||||
"The following incredible projects make developing advanced, modern frontends possible and simple without having to write a single line of JS or CSS. You can go extremely far without leaving the comfort of Go with server-side rendered HTML.",
|
|
||||||
[]Tab{
|
[]Tab{
|
||||||
{
|
{
|
||||||
Title: "HTMX",
|
Title: "HTMX",
|
||||||
|
|
@ -31,15 +31,14 @@ func About(ctx echo.Context) error {
|
||||||
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
|
Body: "Drop-in, Vue-like functionality written directly in your markup. Visit <a href=\"https://alpinejs.dev/\">alpinejs.dev</a> to learn more.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Title: "Bulma",
|
Title: "DaisyUI",
|
||||||
Body: "Ready-to-use frontend components that you can easily combine to build responsive web interfaces with no JavaScript requirements. Visit <a href=\"https://bulma.io/\">bulma.io</a> to learn more.",
|
Body: "DaisyUI is the Tailwind CSS plugin you will love! It provides useful component class names to help you write less code and build faster. No JavaScript requirements. Visit <a href=\"https://daisyui.com/\">daisyui.com</a> to learn more.",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
Div(Class("mb-4")),
|
H2(Text("Backend")),
|
||||||
|
P(Text("The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.")),
|
||||||
Tabs(
|
Tabs(
|
||||||
"Backend",
|
|
||||||
"The following incredible projects provide the foundation of the Go backend. See the repository for a complete list of included projects.",
|
|
||||||
[]Tab{
|
[]Tab{
|
||||||
{
|
{
|
||||||
Title: "Echo",
|
Title: "Echo",
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,12 @@ import (
|
||||||
"entgo.io/ent/entc/load"
|
"entgo.io/ent/entc/load"
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mikestefanello/pagoda/ent/admin"
|
"github.com/mikestefanello/pagoda/ent/admin"
|
||||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
|
||||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/components"
|
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -51,12 +49,12 @@ func AdminEntityList(
|
||||||
r.Title = entityTypeName
|
r.Title = entityTypeName
|
||||||
|
|
||||||
genHeader := func() Node {
|
genHeader := func() Node {
|
||||||
g := make(Group, 0, len(entityList.Columns)+3)
|
g := make(Group, 0, len(entityList.Columns)+2)
|
||||||
g = append(g, Th(Text("ID")))
|
g = append(g, Th(Text("ID")))
|
||||||
for _, h := range entityList.Columns {
|
for _, h := range entityList.Columns {
|
||||||
g = append(g, Th(Text(h)))
|
g = append(g, Th(Text(h)))
|
||||||
}
|
}
|
||||||
g = append(g, Th(), Th())
|
g = append(g, Th())
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,14 +67,14 @@ func AdminEntityList(
|
||||||
g = append(g,
|
g = append(g,
|
||||||
Td(
|
Td(
|
||||||
ButtonLink(
|
ButtonLink(
|
||||||
|
ColorInfo,
|
||||||
r.Path(routenames.AdminEntityEdit(entityTypeName), row.ID),
|
r.Path(routenames.AdminEntityEdit(entityTypeName), row.ID),
|
||||||
"is-link",
|
|
||||||
"Edit",
|
"Edit",
|
||||||
),
|
),
|
||||||
),
|
Span(Class("mr-2")),
|
||||||
Td(
|
ButtonLink(
|
||||||
ButtonLink(r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
|
ColorError,
|
||||||
"is-danger",
|
r.Path(routenames.AdminEntityDelete(entityTypeName), row.ID),
|
||||||
"Delete",
|
"Delete",
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
@ -92,45 +90,27 @@ func AdminEntityList(
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
pagedHref := func(page int) string {
|
|
||||||
return fmt.Sprintf("%s?%s=%d",
|
|
||||||
r.Path(routenames.AdminEntityList(entityTypeName)),
|
|
||||||
pager.QueryKey,
|
|
||||||
page,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.Render(layouts.Primary, Group{
|
return r.Render(layouts.Primary, Group{
|
||||||
|
Div(
|
||||||
|
Class("form-control mb-2"),
|
||||||
ButtonLink(
|
ButtonLink(
|
||||||
|
ColorAccent,
|
||||||
r.Path(routenames.AdminEntityAdd(entityTypeName)),
|
r.Path(routenames.AdminEntityAdd(entityTypeName)),
|
||||||
"is-primary",
|
|
||||||
fmt.Sprintf("Add %s", entityTypeName),
|
fmt.Sprintf("Add %s", entityTypeName),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Table(
|
Table(
|
||||||
Class("table"),
|
Class("table table-zebra mb-2"),
|
||||||
THead(
|
THead(
|
||||||
Tr(genHeader()),
|
Tr(genHeader()),
|
||||||
),
|
),
|
||||||
TBody(genRows()),
|
TBody(genRows()),
|
||||||
),
|
),
|
||||||
Nav(
|
Pager(
|
||||||
Class("pagination"),
|
entityList.Page,
|
||||||
A(
|
r.Path(routenames.AdminEntityAdd(entityTypeName)),
|
||||||
Classes{
|
entityList.HasNextPage,
|
||||||
"pagination-previous": true,
|
"",
|
||||||
"is-disabled": entityList.Page == 1,
|
|
||||||
},
|
|
||||||
If(entityList.Page != 1, Href(pagedHref(entityList.Page-1))),
|
|
||||||
Text("Previous page"),
|
|
||||||
),
|
|
||||||
A(
|
|
||||||
Classes{
|
|
||||||
"pagination-previous": true,
|
|
||||||
"is-disabled": !entityList.HasNextPage,
|
|
||||||
},
|
|
||||||
If(entityList.HasNextPage, Href(pagedHref(entityList.Page+1))),
|
|
||||||
Text("Next page"),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package pages
|
||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
|
|
@ -17,21 +17,25 @@ func ContactUs(ctx echo.Context, form *forms.Contact) error {
|
||||||
|
|
||||||
g := Group{
|
g := Group{
|
||||||
Iff(r.Htmx.Target != "contact", func() Node {
|
Iff(r.Htmx.Target != "contact", func() Node {
|
||||||
return components.Message(
|
return Card(CardParams{
|
||||||
"is-link",
|
Title: "Card component",
|
||||||
"",
|
Body: Group{
|
||||||
Group{
|
Span(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
|
||||||
P(Text("This is an example of a form with inline, server-side validation and HTMX-powered AJAX submissions without writing a single line of JavaScript.")),
|
Span(Text("Only the form below will update async upon submission.")),
|
||||||
P(Text("Only the form below will update async upon submission.")),
|
|
||||||
},
|
},
|
||||||
)
|
Color: ColorWarning,
|
||||||
|
Size: SizeMedium,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
Iff(form.IsDone(), func() Node {
|
Iff(form.IsDone(), func() Node {
|
||||||
return components.Message(
|
return Card(CardParams{
|
||||||
"is-large is-success",
|
Title: "Thank you!",
|
||||||
"Thank you!",
|
Body: Group{
|
||||||
Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled."),
|
Span(Text("No email was actually sent but this entire operation was handled server-side and degrades without JavaScript enabled.")),
|
||||||
)
|
},
|
||||||
|
Color: ColorSuccess,
|
||||||
|
Size: SizeLarge,
|
||||||
|
})
|
||||||
}),
|
}),
|
||||||
Iff(!form.IsDone(), func() Node {
|
Iff(!form.IsDone(), func() Node {
|
||||||
return form.Render(r)
|
return form.Render(r)
|
||||||
|
|
|
||||||
|
|
@ -21,19 +21,19 @@ func UploadFile(ctx echo.Context, files []*models.File) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
n := Group{
|
n := Group{
|
||||||
Message(
|
|
||||||
"is-link",
|
|
||||||
"",
|
|
||||||
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
|
P(Text("This is a very basic example of how to handle file uploads. Files uploaded will be saved to the directory specified in your configuration.")),
|
||||||
),
|
Divider(""),
|
||||||
Hr(),
|
|
||||||
forms.File{}.Render(r),
|
forms.File{}.Render(r),
|
||||||
Hr(),
|
Divider(""),
|
||||||
H3(
|
H3(
|
||||||
Class("title"),
|
Class("title"),
|
||||||
Text("Uploaded files"),
|
Text("Uploaded files"),
|
||||||
),
|
),
|
||||||
Message("is-warning", "", P(Text("Below are all files in the configured upload directory."))),
|
Card(CardParams{
|
||||||
|
Body: Group{Text("Below are all files in the configured upload directory.")},
|
||||||
|
Color: ColorWarning,
|
||||||
|
Size: SizeMedium,
|
||||||
|
}),
|
||||||
Table(
|
Table(
|
||||||
Class("table"),
|
Class("table"),
|
||||||
THead(
|
THead(
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,14 @@
|
||||||
package pages
|
package pages
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mikestefanello/pagoda/pkg/routenames"
|
"github.com/mikestefanello/pagoda/pkg/routenames"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
|
"github.com/mikestefanello/pagoda/pkg/ui/icons"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
"github.com/mikestefanello/pagoda/pkg/ui/models"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
. "maragu.dev/gomponents/components"
|
|
||||||
. "maragu.dev/gomponents/html"
|
. "maragu.dev/gomponents/html"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,69 +36,71 @@ func Home(ctx echo.Context, posts *models.Posts) error {
|
||||||
|
|
||||||
headerMsg := func() Node {
|
headerMsg := func() Node {
|
||||||
return Group{
|
return Group{
|
||||||
Section(
|
Stats(
|
||||||
Class("hero is-info welcome is-small mb-3"),
|
Stat{
|
||||||
Div(
|
Title: "User name",
|
||||||
Class("hero-body"),
|
Value: func() string {
|
||||||
Div(
|
if r.IsAuth {
|
||||||
Class("container"),
|
return r.AuthUser.Name
|
||||||
H1(
|
}
|
||||||
Class("title"),
|
return "(not logged in)"
|
||||||
Iff(r.IsAuth, func() Node {
|
}(),
|
||||||
return Text(fmt.Sprintf("Hello, %s", r.AuthUser.Name))
|
Description: "The logged in user's name",
|
||||||
}),
|
Icon: icons.UserCircle(),
|
||||||
If(!r.IsAuth, Text("Hello")),
|
},
|
||||||
),
|
Stat{
|
||||||
H2(
|
Title: "Admin status",
|
||||||
Class("subtitle"),
|
Value: func() string {
|
||||||
If(!r.IsAuth, Text("Please login in to your account.")),
|
if r.IsAdmin {
|
||||||
If(r.IsAuth, Text("Welcome back!")),
|
return "Administrator"
|
||||||
),
|
}
|
||||||
),
|
return "Non-administrator"
|
||||||
),
|
}(),
|
||||||
),
|
Description: "Use `make admin` to create an admin account",
|
||||||
Section(
|
Icon: icons.LockClosed(),
|
||||||
Class("hero is-light is-small mb-5"),
|
},
|
||||||
Div(
|
Stat{
|
||||||
Class("hero-body"),
|
Title: "GitHub Stars",
|
||||||
Div(
|
Value: "2,500+",
|
||||||
Class("container"),
|
Description: "Star if you like Pagoda",
|
||||||
B(Text("Admin status: ")),
|
Icon: icons.Star(),
|
||||||
Span(
|
|
||||||
Classes{
|
|
||||||
"tag": true,
|
|
||||||
"is-success": r.IsAdmin,
|
|
||||||
"is-danger": !r.IsAdmin,
|
|
||||||
},
|
},
|
||||||
Text(fmt.Sprint(r.IsAdmin)),
|
|
||||||
),
|
),
|
||||||
If(!r.IsAdmin, Span(
|
H2(Text("Recent posts")),
|
||||||
Class("is-size-7 ml-3"),
|
Span(Text("Below is an example of both paging and AJAX fetching using HTMX")),
|
||||||
Raw(`(<a href="https://github.com/mikestefanello/pagoda#create-an-admin-account">click here</a> for instructions to make an admin account)`),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
H2(Class("title"), Text("Recent posts")),
|
|
||||||
H3(Class("subtitle"), Text("Below is an example of both paging and AJAX fetching using HTMX")),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
filesMsg := func() Node {
|
cards := func() Node {
|
||||||
return Message(
|
return Div(
|
||||||
"is-small is-warning mt-5",
|
Class("flex w-full gap-2 mt-5"),
|
||||||
"Serving files",
|
Card(CardParams{
|
||||||
Group{
|
Title: "Serving files",
|
||||||
|
Body: Group{
|
||||||
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
|
Text("In the example posts above, check how the file URL contains a cache-buster query parameter which changes only when the app is restarted. "),
|
||||||
Text("Static files also contain cache-control headers which are configured via middleware."),
|
Text("Static files also contain cache-control headers which are configured via middleware."),
|
||||||
},
|
},
|
||||||
|
Color: ColorWarning,
|
||||||
|
Size: SizeSmall,
|
||||||
|
}),
|
||||||
|
Card(CardParams{
|
||||||
|
Title: "Documentation",
|
||||||
|
Body: Group{
|
||||||
|
Text("Have you read through the entire documentation? If not, you may be missing functionality or have questions. "),
|
||||||
|
},
|
||||||
|
Footer: Group{
|
||||||
|
ButtonLink(ColorNeutral, "https://github.com/mikestefanello/pagoda?tab=readme-ov-file#table-of-contents", "Learn more"),
|
||||||
|
},
|
||||||
|
Color: ColorNeutral,
|
||||||
|
Size: SizeSmall,
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
g := Group{
|
g := Group{
|
||||||
Iff(r.Htmx.Target != "posts", headerMsg),
|
Iff(r.Htmx.Target != "posts", headerMsg),
|
||||||
posts.Render(r.Path(routenames.Home)),
|
posts.Render(r.Path(routenames.Home)),
|
||||||
Iff(r.Htmx.Target != "posts", filesMsg),
|
Iff(r.Htmx.Target != "posts", cards),
|
||||||
}
|
}
|
||||||
|
|
||||||
return r.Render(layouts.Primary, g)
|
return r.Render(layouts.Primary, g)
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package pages
|
||||||
import (
|
import (
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/components"
|
. "github.com/mikestefanello/pagoda/pkg/ui/components"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
"github.com/mikestefanello/pagoda/pkg/ui/forms"
|
||||||
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
"github.com/mikestefanello/pagoda/pkg/ui/layouts"
|
||||||
. "maragu.dev/gomponents"
|
. "maragu.dev/gomponents"
|
||||||
|
|
@ -17,23 +17,23 @@ func AddTask(ctx echo.Context, form *forms.Task) error {
|
||||||
|
|
||||||
g := Group{
|
g := Group{
|
||||||
Iff(r.Htmx.Target != "task", func() Node {
|
Iff(r.Htmx.Target != "task", func() Node {
|
||||||
return components.Message(
|
return Group{
|
||||||
"is-link",
|
|
||||||
"",
|
|
||||||
Group{
|
|
||||||
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
|
P(Raw("Submitting this form will create an <i>ExampleTask</i> in the task queue. After the specified delay, the message will be logged by the queue processor.")),
|
||||||
P(Raw("See <i>pkg/tasks</i> and the README for more information.")),
|
P(Raw("See <i>pkg/tasks</i> and the README for more information.")),
|
||||||
})
|
}
|
||||||
}),
|
}),
|
||||||
form.Render(r),
|
form.Render(r),
|
||||||
Iff(r.Htmx.Target != "task", func() Node {
|
Iff(r.Htmx.Target != "task", func() Node {
|
||||||
return components.Message(
|
var text string
|
||||||
"is-warning",
|
if r.IsAdmin {
|
||||||
"",
|
text = "View all queued tasks by clicking on the Tasks link in the sidebar."
|
||||||
Group{
|
} else {
|
||||||
If(!r.IsAdmin, P(Text("Log in as an admin in order to access the task and queue monitoring UI."))),
|
text = "Log in as an admin in order to access the task and queue monitoring UI."
|
||||||
If(r.IsAdmin, P(Text("View all queued tasks by clicking on the Tasks link in the sidebar."))),
|
}
|
||||||
})
|
return Group{
|
||||||
|
Div(Class("mt-5")),
|
||||||
|
Alert(ColorWarning, text),
|
||||||
|
}
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ type (
|
||||||
}
|
}
|
||||||
|
|
||||||
// LayoutFunc is a callback function intended to render your page node within a given layout.
|
// LayoutFunc is a callback function intended to render your page node within a given layout.
|
||||||
// This is handled as a callback in order to automatically support HTMX requests so that you can respond
|
// This is handled as a callback to automatically support HTMX requests so that you can respond
|
||||||
// with only the page content and not the entire layout.
|
// with only the page content and not the entire layout.
|
||||||
// See Request.Render().
|
// See Request.Render().
|
||||||
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
|
LayoutFunc func(*Request, gomponents.Node) gomponents.Node
|
||||||
|
|
|
||||||
13
pkg/ui/ui.go
13
pkg/ui/ui.go
|
|
@ -3,8 +3,6 @@ package ui
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mikestefanello/pagoda/config"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -12,7 +10,12 @@ var (
|
||||||
cacheBuster = fmt.Sprint(time.Now().Unix())
|
cacheBuster = fmt.Sprint(time.Now().Unix())
|
||||||
)
|
)
|
||||||
|
|
||||||
// File generates a relative URL to a static file including a cache-buster query parameter.
|
// PublicFile generates a relative URL to a public file.
|
||||||
func File(filepath string) string {
|
func PublicFile(filepath string) string {
|
||||||
return fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, filepath, cacheBuster)
|
return fmt.Sprintf("/%s/%s", "files", filepath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StaticFile generates a relative URL to a static file including a cache-buster query parameter.
|
||||||
|
func StaticFile(filepath string) string {
|
||||||
|
return fmt.Sprintf("/%s/%s?v=%s", "static", filepath, cacheBuster)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,19 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/mikestefanello/pagoda/config"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFile(t *testing.T) {
|
func TestPublicFile(t *testing.T) {
|
||||||
path := "abc.txt"
|
path := "abc.txt"
|
||||||
got := File(path)
|
got := PublicFile(path)
|
||||||
expected := fmt.Sprintf("/%s/%s?v=%s", config.StaticPrefix, path, cacheBuster)
|
expected := fmt.Sprintf("/%s/%s", "files", path)
|
||||||
|
assert.Equal(t, expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStaticFile(t *testing.T) {
|
||||||
|
path := "abc.txt"
|
||||||
|
got := StaticFile(path)
|
||||||
|
expected := fmt.Sprintf("/%s/%s?v=%s", "static", path, cacheBuster)
|
||||||
assert.Equal(t, expected, got)
|
assert.Equal(t, expected, got)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
0
public/files/.gitkeep
Normal file
0
public/files/.gitkeep
Normal file
8
public/fs.go
Normal file
8
public/fs.go
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
package files
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed static
|
||||||
|
var Static embed.FS
|
||||||
|
Before Width: | Height: | Size: 972 B After Width: | Height: | Size: 972 B |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
BIN
public/static/logo.png
Normal file
BIN
public/static/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 29 KiB |
2
public/static/main.css
Normal file
2
public/static/main.css
Normal file
File diff suppressed because one or more lines are too long
9
tailwind.css
Normal file
9
tailwind.css
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
@import "tailwindcss" source(none);
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
@source "./pkg/ui/**/*.{go}";
|
||||||
|
@plugin "./daisyui.js";
|
||||||
|
|
||||||
|
/* Optional for custom themes – Docs: https://daisyui.com/docs/themes/#how-to-add-a-new-custom-theme */
|
||||||
|
@plugin "./daisyui-theme.js"{
|
||||||
|
/* custom theme here */
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue