init - add project files

This commit is contained in:
2025-03-06 23:54:11 -05:00
commit e724ff1120
1363 changed files with 897467 additions and 0 deletions

21
ui/alpine.go Normal file
View File

@@ -0,0 +1,21 @@
package ui
import (
"fmt"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
type AlpineStore map[string]string
func (as AlpineStore) Init() Node {
script := "document.addEventListener('alpine:init', () => {"
for k, v := range as {
script += fmt.Sprintf("Alpine.store('%s', %s);", k, v)
}
script += "})"
return Script(Raw(script))
}

175
ui/app_layout.go Normal file
View File

@@ -0,0 +1,175 @@
package ui
import (
"maxwarden/auth"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
const (
LAYOUT_SECTION_VAULT = iota
LAYOUT_SECTION_TOOLS = iota
LAYOUT_SECTION_ACCOUNT = iota
LAYOUT_SECTION_API = iota
)
type NavGroup struct {
SectionId int
Title string
URL string
SubGroup []NavGroup
NewTab bool
}
var NavGroups = []NavGroup{
{SectionId: LAYOUT_SECTION_VAULT, Title: "Vault", URL: "/app", SubGroup: nil},
{
Title: "Tools",
SubGroup: []NavGroup{
{SectionId: LAYOUT_SECTION_TOOLS, Title: "Password Generator", URL: "/app/examples/forms"},
},
},
}
func AppLayout(title string, identity auth.Identity, session map[string]interface{}, children ...Node) Node {
navbarDropdown := func(dropdownHeader Node, dropdownItems Node) Node {
return Div(
InlineStyle("$me{cursor: pointer; position: relative; margin-left: $3;}"),
Div(
Class("button"),
InlineStyle(`
$me {
cursor: pointer;
display: flex;
position: relative;
padding-top: $2;
padding-bottom: $2;
padding-left: $3;
font-size: var(--text-sm);
line-height: var(--text-sm--line-height);
font-weight: var(--font-weight-medium);
color: $color(white);
}
$me:hover{color: $color(white);}
`),
Button(
Div(InlineStyle("$me{cursor: pointer; display: flex; align-items: center;}"),
dropdownHeader, Span(Text(" ")),
Icon(ICON_CHEVRON_DOWN, 16),
),
),
),
Div(
Class("dropdown"),
InlineStyle(`$me{display: none; position: absolute; right: 0; z-index: 10; padding-top: $1; padding-bottom: $1; margin-top: $2; width: $48; background-color: $color(white); transform-origin: top right; box-shadow: var(--shadow-lg);}`),
TabIndex("-1"),
dropdownItems,
),
InlineScript(`
let button = me(".button", me());
let dropdown = me(".dropdown", me());
button.on("click", ev => { toggleShowHide(dropdown) });
onClickOutsideOrEscape(me(), () => { hide(dropdown) });
`),
)
}
navbarDropdownItem := func(name string, url string, newPage bool) Node {
return A(InlineStyle(`$me{display: block; padding-top: $2; padding-bottom: $2; padding-left: $4; padding-right: $4; font-size: var(--text-sm); line-height: $5; color: $color(neutral-700); } $me:hover{background: $color(neutral-100);}`), Href(url), TabIndex("-1"), Text(name), If(newPage, Target("_blank")))
}
navbarLink := func(name string, url string, newPage bool) Node {
return A(
InlineStyle(`$me{ padding-left: $3; padding-right: $3; padding-top: $2; padding-bottom: $2; font-size: var(--text-sm); font-weight: var(--font-weight-medium); color: $color(white);}`),
InlineStyle("$me:hover{color: $color(white);}"),
Href(url),
Text(name),
If(newPage, Target("_blank")),
)
}
return RootLayout(title+" | MaxWarden",
Body(InlineStyle("$me{background-color: $color(light-grey); height: 100%;}"),
Div(InlineStyle("$me{min-height: 100%}"),
Nav(InlineStyle("$me{background-color: $color(deep-blue);}"),
Div(InlineStyle("$me{margin-left: auto; margin-right: auto; max-width: var(--container-7xl);}"),
Div(InlineStyle("$me{display: flex; height: $16; align-items: center; justify-content: space-between;}"),
Div(InlineStyle("$me{align-items: center; display: flex;}"),
Div(InlineStyle("@media $lg-{ $me{display: block;}}"),
Div(InlineStyle(`$me{margin-left: $1; display: flex; align-items: baseline;} $me:not(:last-child){ margin-left: $4; }`),
Map(NavGroups, func(nav NavGroup) Node {
if len(nav.SubGroup) > 0 {
return navbarDropdown(
Text(nav.Title),
Map(nav.SubGroup, func(sub NavGroup) Node {
return navbarDropdownItem(sub.Title, sub.URL, sub.NewTab)
}),
)
} else {
return navbarLink(nav.Title, nav.URL, nav.NewTab)
}
}),
),
),
),
Div(InlineStyle("$me{display: none;} @media $md { $me{ display: block; }}"),
Div(InlineStyle("$me{ margin-left: $4; display: flex; align-items: center;} @media $md { $me{ margin-left: $6;}}"),
Div(InlineStyle("$me{position: relative; margin-left: $3;}"),
navbarDropdown(
Icon(ICON_USERS, 24),
Group{
navbarDropdownItem("My Profile", "/app/account", false),
navbarDropdownItem("Logout", "/auth/logout", false),
},
),
),
),
),
Div(InlineStyle("$me{margin-right: $2; display: flex;} @media $md{ $me{display: none;}}"),
Button(
InlineStyle("$me{position: relative; display: inline-flex; justify-items: center; padding: $2; color: $color(neutral-400)}"),
InlineStyle("$me:hover{color: $color(white); background-color: $color(neutral-900);}"),
Type("button"),
Span(InlineStyle("$me{position: absolute;}")),
Icon(ICON_MENU, 24),
),
),
),
),
Div(InlineStyle("@media $md { $me {display: none; }}"),
Div(Class("space-y-1 px-2 pb-3 pt-2 sm:px-3"),
A(Href("/app/dashboard"), Class("block hover:bg-neutral-900 px-3 py-2 text-base font-medium text-white"), Text("Dashboard")),
),
Div(Class("border-t border-neutral-700 pb-3 pt-4"),
Div(Class("flex items-center px-5"),
Div(Class("flex-shrink-0"),
Img(Class("h-10 w-10 rounded-full"), Src(""), Alt("profile picture")),
),
Div(Class("ml-3"),
// Div(Class("text-base/5 font-medium text-white"), Text(identity.User.Firstname+" "+identity.User.Lastname)),
// Div(Class("text-sm font-medium text-neutral-400"), Text(identity.User.Email)),
),
),
Div(Class("mt-3 space-y-1 px-2"),
A(Href("/auth/logout"), Class("block px-3 py-2 text-base font-medium text-neutral-200 hover:bg-neutral-900 hover:text-white"), Text("Log out")),
),
),
),
),
Header(InlineStyle("$me{background-color: $color(white); box-shadow: var(--shadow-sm);}"),
Div(InlineStyle("$me{margin-left: auto; margin-right: auto; max-width: var(--container-7xl); padding: $4;} @media $lg { $me{ padding-left: $8; padding-right: $8;}}"),
H1(InlineStyle("$me{font-size: var(--text-3xl); font-weight: var(--font-weight-bold); color: $color(neutral-950); letter-spacing: var(--tracking-tight);}"), Text(title)),
),
),
Main(
Div(InlineStyle("$me{margin-left: auto; margin-right: auto; max-width: var(--container-7xl); padding: $6 $4;} @media $sm { $me{padding-left: $6; padding-right: $6; }} @media $lg { $me{padding-left: $8; padding-right: $8;}}"),
Group(children),
),
),
),
),
)
}

375
ui/autotable.go Normal file
View File

@@ -0,0 +1,375 @@
package ui
import (
. "maxwarden/basic"
"maxwarden/database"
. "maragu.dev/gomponents"
hx "maragu.dev/gomponents-htmx"
. "maragu.dev/gomponents/html"
)
const TABLE_ABOVE_PREFIX = "_tableAbove"
const FORM_BIND_SUFFIX = "_form"
const FORM_PAGINATION_SUFFIX = "_paginationForm"
// FILTER COMPONENTS
// Autotable bind search to col
func BindSearch(elId string, identifier string) Node {
return Group{
FormAttr(elId + FORM_BIND_SUFFIX),
Name(database.SEARCH_URL_KEY_PREFIX + identifier),
}
}
const (
AUTOTABLE_HEADER_COLOR_DEFAULT = iota
)
type AutoTableOptions struct {
Compact bool
Shadow bool
Hover bool // highlight rows when hovering over them
Alternate bool // highlight alternating rows
HeaderBorderY bool
BorderX bool
BorderY bool
Color int // "enum"
}
// THE TABLE
// Note that "aboveTable" node is not swapped with HTMX, but "belowTable" is.
func AutoTable[E any](tableId string, url string, cols []database.ColInfo, f database.Filter, entities []E, aboveTable Node, rowComponent func(E) Node, belowTable Node, opts AutoTableOptions) Node {
paginationButton := func(icon string, page int) Node {
return Button(
InlineStyle(`
$me {
padding-left: $3;
padding-right: $3;
padding-top: $1;
padding-bottom: $1;
min-height: $9;
font-size: var(--text-sm);
font-weight: var(--font-weight-normal);
color: $color(neutral-800);
cursor: pointer;
}
`),
Icon(icon, 16),
hx.Get(url+database.QueryParamsFromPagenum(page, f)),
hx.Swap(CSSID(tableId)),
hx.Target(CSSID(tableId)),
hx.Select(CSSID(tableId)),
hx.Trigger("click"),
)
}
return Group{
If((aboveTable != nil) && (tableId != ""),
Div(ID(tableId+TABLE_ABOVE_PREFIX),
aboveTable,
),
),
Div(
If(tableId != "",
Group{
ID(tableId),
Form(ID(tableId+FORM_BIND_SUFFIX),
AutoComplete("off"),
hx.Get(url),
hx.Trigger("keyup delay:100ms from:(#"+tableId+TABLE_ABOVE_PREFIX+" input), change from:(#"+tableId+TABLE_ABOVE_PREFIX+" input[type=date]), change from:(#"+tableId+TABLE_ABOVE_PREFIX+" input[type=datetime-local]), change from:(#"+tableId+TABLE_ABOVE_PREFIX+" select)"),
hx.Swap("outerHTML"),
hx.Target(CSSID(tableId)),
hx.Select(CSSID(tableId)),
Input(Type("hidden"), Name(database.ORDER_BY_URL_KEY), Value(f.OrderBy)),
Input(Type("hidden"), Name(database.ORDER_DESC_URL_KEY), Value(ToString(f.OrderDescending))),
Input(Type("hidden"), Name(database.ITEMS_PER_PAGE_URL_KEY), Value(ToString(f.Pagination.MaxItemsPerPage))),
),
},
),
Div(
If(opts.Shadow,
InlineStyle(`
$me {
background-color: $color(white);
box-shadow: var(--shadow-md);
}
`),
),
InlineStyle(`
$me {
position: relative;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
color: $color(gray-700);
background-color: $color(white);
}
`),
Div(
InlineStyle(`
$me {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
`),
Table(
InlineStyle("$me { table-layout: fixed; width: 100%; }"),
// ---- TABLE HEADER ----
THead(
Tr(
Map(cols, func(col database.ColInfo) Node {
return Th(
If(col.DisplayPosition == database.COL_POS_RIGHT,
InlineStyle("$me { text-align: right; }"),
),
InlineStyle(`
$me {
border-bottom: 1px solid $color(neutral-200);
}
`),
If(opts.HeaderBorderY,
InlineStyle(`
$me:not(:first-child) {
border-left: 1px solid $color(neutral-200);
}
`),
),
IfElse(opts.Compact,
InlineStyle("$me { padding: $2 $3; }"),
InlineStyle("$me { padding: $4 $4; }"),
),
If(col.Sortable,
Group{
hx.Get(url + database.QueryParamsFromOrderBy(col.DbName, !f.OrderDescending && (col.DbName == f.OrderBy), f)),
hx.Swap(CSSID(tableId)),
hx.Target(CSSID(tableId)),
hx.Select(CSSID(tableId)),
hx.Trigger("click"),
InlineStyle(`
$me {
cursor: pointer;
}
$me:hover {
background-color: $color(neutral-200);
}
`),
},
),
P(
InlineStyle(`
$me {
display: flex;
justify-content: space-between;
align-items: center;
gap: $2;
font-size: var(--text-sm);
color: $color(neutral-800);
font-weight: var(--font-weight-bold);
}
`),
If(col.DisplayPosition == database.COL_POS_LEFT,
InlineStyle("$me { flex-direction: row; }"),
),
If(col.DisplayPosition == database.COL_POS_RIGHT,
InlineStyle("$me { flex-direction: row-reverse; }"),
),
Text(col.DisplayName),
If((f.OrderBy == col.DbName) && (col.DbName != ""),
Group{
InlineStyle("$me { color: $color(black); text-decoration: underline; }"),
IfElse(f.OrderDescending,
Icon(ICON_ARROW_DOWN, 16),
Icon(ICON_ARROW_UP, 16),
),
},
),
),
)
}),
),
),
// ---- TABLE BODY ----
TBody(
InlineStyle(`
$me > tr > td {
overflow-x: auto;
font-size: var(--text-sm);
color: $color(neutral-700);
}
`),
If(opts.Alternate,
InlineStyle("$me > tr:nth-child(even) { background: $color(neutral-100) }"),
),
If(opts.Hover && len(entities) > 0,
InlineStyle("$me > tr:hover {background-color: $color(neutral-100);}"),
),
If(opts.BorderX && f.Pagination.Enabled,
InlineStyle("$me > tr {border-bottom: 1px solid $color(neutral-200); }"),
),
If(opts.BorderX && !f.Pagination.Enabled,
InlineStyle("$me > tr:not(:last-child) {border-bottom: 1px solid $color(neutral-200); }"),
),
If(opts.BorderY,
InlineStyle(`
$me > tr > td:not(:first-child) {
border-left: 1px solid $color(neutral-200);
}
`),
),
IfElse(opts.Compact,
InlineStyle("$me > tr > td { padding: $1 $3; }"),
InlineStyle("$me > tr > td { padding: $4; }"),
),
IfElse(len(entities) > 0,
Map(entities, func(e E) Node {
return rowComponent(e)
}),
Tr(
Td(
Text("Dataset contains no entries."),
),
),
),
),
),
),
// ---- PAGINATION ----
If(f.Pagination.Enabled,
Div(InlineStyle("$me { display: flex; justify-content: space-between; align-items: center; }"),
IfElse(opts.Compact,
InlineStyle("$me { padding: $1 $4; }"),
InlineStyle("$me { padding: $3 $4; }"),
),
Div(
InlineStyle("$me { display: flex; align-items: center; font-size: var(--text-sm); color: $color(neutral-500); }"),
B(Icon(ICON_LIST_ORDERED, 16)),
Span(InlineStyle("$me {margin-right: $3;}")), ToText(f.Pagination.ViewRangeLower), Text("-"), ToText(f.Pagination.ViewRangeUpper), Text(" of "), ToText(f.Pagination.TotalItems),
),
Div(InlineStyle("$me { display: flex; align-items: center; }"),
Div(InlineStyle("$me { margin-right: $3; font-size: var(--text-sm); color: $color(neutral-500);}"),
Span(Text("Items per page:")),
),
Form(
ID(tableId+FORM_PAGINATION_SUFFIX),
hx.Get(url),
hx.Trigger("change from:(#"+tableId+FORM_PAGINATION_SUFFIX+" select)"),
hx.Target(CSSID(tableId)),
hx.Select(CSSID(tableId)),
hx.Swap("outerHTML"),
MapMapWithKey(f.Search, func(s string, v string) Node {
return Input(Type("hidden"), Name(database.SEARCH_URL_KEY_PREFIX+s), Value(v))
}),
Input(Type("hidden"), Name(database.ORDER_BY_URL_KEY), Value(f.OrderBy)),
Input(Type("hidden"), Name(database.ORDER_DESC_URL_KEY), Value(ToString(f.OrderDescending))),
Select(
IfElse(opts.Compact,
InlineStyle("$me { padding: $2; }"),
InlineStyle("$me { padding: $3; }"),
),
InlineStyle(`
$me {
background-color: $color(gray-50);
border: 1px solid $color(gray-50);
font-size: var(--text-sm);
display: block;
margin-right: $3;
box-shadow: var(--shadow-sm);
}
`),
Name(database.ITEMS_PER_PAGE_URL_KEY),
Option(If(f.Pagination.MaxItemsPerPage == 5, Selected()), Text("5"), Value("5")),
Option(If(f.Pagination.MaxItemsPerPage == 10, Selected()), Text("10"), Value("10")),
Option(If(f.Pagination.MaxItemsPerPage == 25, Selected()), Text("25"), Value("25")),
Option(If(f.Pagination.MaxItemsPerPage == 50, Selected()), Text("50"), Value("50")),
Option(If(f.Pagination.MaxItemsPerPage == 100, Selected()), Text("100"), Value("100")),
),
),
paginationButton(ICON_CHEVRON_FIRST, 1),
paginationButton(ICON_CHEVRON_LEFT, f.Pagination.PreviousPage),
Div(
InlineStyle("$me { font-size: var(--text-sm); display: flex; justify-content: center; color: $color(neutral-500); padding: $3; min-height: $9; }"),
Text("Page "),
ToText(f.Pagination.CurrentPage),
Text(" of "),
ToText(f.Pagination.TotalPages),
),
paginationButton(ICON_CHEVRON_RIGHT, f.Pagination.NextPage),
paginationButton(ICON_CHEVRON_LAST, f.Pagination.TotalPages),
),
),
),
),
belowTable,
),
}
}
func AutotableSearchGroup(children ...Node) Node {
return Div(InlineStyle("$me { width: 100%; display: flex; justify-content: space-between; margin-bottom: $3; margin-top: $1; }"),
Div(InlineStyle("$me { width: 100%; position: relative; }"),
Div(InlineStyle("$me { position: relative; display: flex; flex-direction: row; align-items: center; gap: $1;}"),
Group(children),
),
),
)
}
func AutotableSearchDropdown(c ...Node) Node {
return Div(
InlineStyle(`
$me {
width: 100%;
}
`),
FormSelect(Group(c)),
)
}
func AutotableSearch(c ...Node) Node {
return Div(
InlineStyle(`
$me {
width: 100%;
}
`),
FormInput(Group(c)),
)
}
// "premade" autotable for simple tables that don't feature dynamic filtering.
// We can still use the styling and general form of the fancy autotable defined above, but
// for simple datasets because why not. This also gives you the option to "upgrade"
// to the "full" table later on, since you are using the same api
func AutoTableLite[E any](columnNames []string, entities []E, rowComponent func(E) Node, opts AutoTableOptions) Node {
cols := []database.ColInfo{}
for _, v := range columnNames {
cols = append(cols, database.ColInfo{DisplayName: v})
}
return AutoTable(
"",
"",
cols,
database.Filter{},
entities,
nil,
rowComponent,
nil,
opts,
)
}

79
ui/buttons.go Normal file
View File

@@ -0,0 +1,79 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// BUTTONS
func ButtonUI(children ...Node) Node {
return Button(
InlineStyle(`
$me {
cursor: pointer;
background-color: $color(deep-blue);
color: $color(neutral-50);
padding: $1.5 $8 $1.5 $8;
font-size: var(--text-sm);
border-radius: var(--radius-xs);
font-weight: var(--font-weight-semibold);
}
$me:hover {
background: $color(indigo-blue);
}
`),
Group(children),
)
}
func ButtonUIOutline(children ...Node) Node {
return ButtonUI(
InlineStyle(`
$me {
background: none;
box-sizing: border-box;
box-shadow: 0 0 0 0, inset 0 0 0 1px $color(gray-600);
color: $color(gray-600);
}
$me:hover {
background: none;
box-shadow: 0 0 0 0, inset 0 0 0 2px $color(gray-600);
}
`),
Group(children),
)
}
func ButtonUIDanger(children ...Node) Node {
return ButtonUI(
InlineStyle(`
$me {
background: $color(red-700);
color: $color(white);
}
$me:hover {
background: $color(red-800);
}
`),
Group(children),
)
}
func ButtonUISuccess(children ...Node) Node {
return ButtonUI(
InlineStyle(`
$me {
background: $color(green-700);
color: $color(white);
}
$me:hover {
background: $color(green-800);
}
`),
Group(children),
)
}

102
ui/error.go Normal file
View File

@@ -0,0 +1,102 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func ErrorPage(status int) Node {
return RootLayout("An error has occurred.",
Section(
InlineStyle(`
$me {
background: $color(white);
}
`),
Div(
InlineStyle(`
$me {
padding-top: $8;
padding-bottom: $8;
padding-left: $4;
padding-right: $4;
margin-right: auto;
margin-left: auto;
max-width: var(--container-xl);
}
@media $lg {
$me {
padding-top: $16;
padding-bottom: $16;
padding-right: $6;
padding-left: $6;
}
}
`),
Div(
InlineStyle(`
$me {
margin-right: auto;
margin-left: auto;
max-width: var(--container-sm);
text-align: center
}
`),
H1(
InlineStyle(`
$me {
margin-bottom: $4;
font-size: var(--text-7xl);
color: $color(indigo-blue);
letter-spacing: var(--tracking-tight);
}
@media $lg {
$me {
font-size: var(--text-9xl);
}
}
`),
ToText(status),
),
P(
InlineStyle(`
$me {
margin-bottom: $4;
font-size: var(--text-lg);
color: $color(neutral-500);
}
`),
Text("We're sorry, an error has occured."),
),
A(
Href("/"),
InlineStyle(`
$me {
display: inline-flex;
color: $color(white);
background: $color(indigo-blue);
font-weight: var(--font-weight-medium);
border-radius: var(--radius-xs);
font-size: var(--text-sm);
padding-left: $5;
padding-right: $5;
padding-top: $3;
padding-bottom: $3;
text-align: center;
margin-top: $4;
margin-bottom: $4;
}
$me:hover {
background: $color(neutral-950);
}
`),
Text("Back to Homepage"),
),
),
),
),
)
}

76
ui/extensions.go Normal file
View File

@@ -0,0 +1,76 @@
// This file contains a few extensions to the "gomponents" library.
package ui
import (
"maxwarden/security"
. "maragu.dev/gomponents"
)
// Map a `map[T]U` to a [Group]
func MapMap[T comparable, U comparable](m map[T]U, cb func(U) Node) Group {
var nodes []Node
for k, _ := range m {
nodes = append(nodes, cb(m[k]))
}
return nodes
}
// Map a `map[T]U` to a [Group]
// The callback provided must take the map key as an argument
func MapMapWithKey[T comparable, U comparable](m map[T]U, cb func(T, U) Node) Group {
var nodes []Node
for k, _ := range m {
nodes = append(nodes, cb(k, m[k]))
}
return nodes
}
// Map a slice of anything to a [Group] (which is just a slice of [Node]-s).
// The callback must accept the index as an argument.
func MapWithIndex[T any](collection []T, callback func(index int, item T) Node) Group {
nodes := make([]Node, 0, len(collection))
for index, item := range collection {
nodes = append(nodes, callback(index, item))
}
return nodes
}
func IfElse(condition bool, t Node, f Node) Node {
if condition {
return t
} else {
return f
}
}
func IffElse(condition bool, t func() Node, f func() Node) Node {
if condition {
return t()
} else {
return f()
}
}
// Sanitizes user input HTML
func SafeRaw(html string) Node {
sanitized := security.SanitizationPolicy.Sanitize(html)
return Raw(sanitized)
}
func CSSID(input string) string {
return "#" + input
}
// For some reason this isn't included in the base distribution
func Template(children ...Node) Node {
return El("template", children...)
}
func Open() Node {
return Attr("open")
}

76
ui/forms.go Normal file
View File

@@ -0,0 +1,76 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// FORMS
func FormInput(children ...Node) Node {
return Input(
InlineStyle(`
$me {
background-color: $color(white);
padding: $2;
display: block;
width: 100%;
border: 0;
color: $color(neutral-900);
border-bottom: 1px solid $color(neutral-300);
box-shadow: var(--shadow-xs);
}
@media $sm {
$me {
font-size: var(--text-sm);
}
}
`),
Group(children),
)
}
func FormSelect(children ...Node) Node {
return Select(
InlineStyle(`
$me {
padding: $3;
background-color: $color(white);
display: block;
width: 100%;
border: 0;
color: $color(neutral-900);
border-bottom: 1px solid $color(neutral-300);
box-shadow: var(--shadow-xs);
}
@media $sm {
$me {
font-size: var(--text-sm);
}
}
`),
Group(children),
)
}
func FormTextarea(children ...Node) Node {
return Textarea(
InlineStyle(`
$me {
display: block;
padding: $3;
width: 100%;
font-size: var(--text-sm);
color: $color(neutral-900);
background-color: $color(white);
border-bottom: 1px solid $color(neutral-300);
box-shadow: var(--shadow-xs);
}
`),
Group(children),
)
}
func FormLabel(children ...Node) Node {
return Label(InlineStyle("$me { color: $color(neutral-900); font-size: var(--text-sm); } "), Group(children))
}

168
ui/general.go Normal file
View File

@@ -0,0 +1,168 @@
package ui
import (
. "maxwarden/basic"
"time"
. "maragu.dev/gomponents"
hx "maragu.dev/gomponents-htmx"
. "maragu.dev/gomponents/html"
)
// DUMMY TEXT
const LOREM_IPSUM = `Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aliquam ultrices iaculis dui sed porttitor. Integer sed est fringilla, condimentum magna ac, sodales dui. Sed tempor pretium scelerisque. Vivamus pulvinar iaculis libero nec blandit. Mauris tempus velit in neque elementum, ac elementum diam feugiat. Aenean malesuada, nunc a interdum volutpat, diam est lacinia magna, nec fermentum massa lectus non urna. Cras vitae turpis finibus, porta est tincidunt, efficitur neque. Suspendisse suscipit a nulla mollis sodales. Nam vitae nulla vulputate, dictum purus eget, malesuada justo. Vestibulum ultricies eget neque ac volutpat. Mauris et molestie elit. Donec et suscipit urna. Duis in mi in ipsum faucibus finibus.`
// Splitters / dividers / section splits
func Divider() Node {
return Hr(
InlineStyle("$me { color: $color(neutral-200); margin-bottom: $3; margin-top: $1; }"),
)
}
// By default, HTML element styles are reset by tailwind's preflight css
// (we use their styles even though we aren't using tailwind).
// This element reverts all child elements back to the _browser_ defaults,
// and applies additional styles to make user-input HTML look nicer.
//
// Useful for markdown content, blogs, etc.
func Prose(children ...Node) Node {
return Div(
InlineStyle(`
$me * {
all: revert;
font-family: var(--font-sans);
}
`),
Group(children),
)
}
// BADGES
func BadgeSuccess(children ...Node) Node {
return Span(Class("w-fit inline-flex overflow-hidden rounded-sm border border-green-500 bg-white text-xs font-medium text-green-500 dark:border-green-500 dark:bg-neutral-950 dark:text-green-500"),
Span(Class("px-2 py-1 bg-green-500/10 dark:bg-green-500/10"), Group(children)),
)
}
func BadgeWarning(children ...Node) Node {
return Span(Class("w-fit inline-flex overflow-hidden rounded-sm border border-amber-500 bg-white text-xs font-medium text-amber-500 dark:border-amber-500 dark:bg-neutral-950 dark:text-amber-500"),
Span(Class("px-2 py-1 bg-amber-500/10 dark:bg-amber-500/10"), Group(children)),
)
}
// CONTAINERS
func Flex(n ...Node) Node {
return Div(
InlineStyle("$me { display: flex; align-items: center; flex-direction: row; gap: $3; }"),
Group(n),
)
}
func CardNoPadding(body ...Node) Node {
return Div(
InlineStyle(`
$me {
background-color: $color(white);
box-shadow: var(--shadow-xs);
}
`),
Group(body),
)
}
func Card(body ...Node) Node {
return Div(
InlineStyle(`
$me {
background-color: $color(white);
padding: $5;
box-shadow: var(--shadow-xs);
}
`),
Group(body),
)
}
// EMAIL
func ExampleEmailComponent(body string) Node {
return EmailRoot(
H1(Text("This email is automatically generated.")),
P(Text(body)),
)
}
// FORMATTERS
func FormatTime(utcTime time.Time) Node {
return Text(TimeToTimeString(utcTime))
}
func FormatDateTime(utcTime time.Time) Node {
return Text(TimeToString(utcTime))
}
func FormatDate(utcTime time.Time) Node {
return Text(DateToString(utcTime))
}
func ToText(i interface{}) Node {
return Text(ToString(i))
}
// TEXT
func PageLink(location string, display Node, newPage bool) Node {
return A(
Href(location),
InlineStyle("$me{text-decoration: underline; color: $color(blue-600);} $me:hover{color: $color(blue-800);}"),
display,
If(newPage, Target("_blank")),
)
}
// TABLES
// Row Item Helpers
func TdLeft(c ...Node) Node {
return Td(InlineStyle("$me { text-align: left; }"), Group(c))
}
func TdRight(c ...Node) Node {
return Td(InlineStyle("$me { text-align: right; }"), Group(c))
}
func TdCenter(c ...Node) Node {
return Td(InlineStyle("$me { text-align: center; }"), Group(c))
}
// HTMX Helpers
func HxLoad(url string) Node {
return Div(hx.Get(url), hx.Trigger("load"),
Loader(),
)
}
func Loader() Node {
return Div(
InlineStyle(`
$me {
width: 48px;
height: 48px;
border: 5px solid #FFF;
border-bottom-color: $color(neutral-800);
border-radius: 50%;
display: inline-block;
box-sizing: border-box;
animation: rotation 1s linear infinite;
margin: 0 auto;
}
@keyframes rotation {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`),
)
}

28
ui/grid.go Normal file
View File

@@ -0,0 +1,28 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func Grid2x2(children ...Node) Node {
return Div(
InlineStyle(`
$me {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $5;
padding: $5
max-width: var(--container-lg);
margin: 0 auto;
}
@media $md- {
$me {
grid-template-columns: 1fr;
}
}
`),
Group(children),
)
}

57
ui/icons.go Normal file
View File

@@ -0,0 +1,57 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
"strconv"
)
// make sure each icon does not have a height width attribute set! this gets appended at the component level!
const (
// primary icons
// https://lucide.dev
ICON_ARROW_UP_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-down"><path d="m21 16-4 4-4-4"/><path d="M17 20V4"/><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/></svg>`
ICON_ARROW_DOWN_WIDE_NARROW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down-wide-narrow"><path d="m3 16 4 4 4-4"/><path d="M7 20V4"/><path d="M11 4h10"/><path d="M11 8h7"/><path d="M11 12h4"/></svg>`
ICON_ARROW_UP_WIDE_NARROW = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up-wide-narrow"><path d="m3 8 4-4 4 4"/><path d="M7 4v16"/><path d="M11 12h10"/><path d="M11 16h7"/><path d="M11 20h4"/></svg>`
ICON_ARROW_UP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-up"><path d="m5 12 7-7 7 7"/><path d="M12 19V5"/></svg>`
ICON_ARROW_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-down"><path d="M12 5v14"/><path d="m19 12-7 7-7-7"/></svg>`
ICON_ARROW_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-right"><path d="M5 12h14"/><path d="m12 5 7 7-7 7"/></svg>`
ICON_ARROW_LEFT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-arrow-left"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>`
ICON_CHEVRON_FIRST = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-first"><path d="m17 18-6-6 6-6"/><path d="M7 6v12"/></svg>`
ICON_CHEVRON_LAST = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-last"><path d="m7 18 6-6-6-6"/><path d="M17 6v12"/></svg>`
ICON_CHEVRON_LEFT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-left"><path d="m15 18-6-6 6-6"/></svg>`
ICON_CHEVRON_RIGHT = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-right"><path d="m9 18 6-6-6-6"/></svg>`
ICON_CHEVRON_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-down"><path d="m6 9 6 6 6-6"/></svg>`
ICON_CHEVRON_UP = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevron-up"><path d="m18 15-6-6-6 6"/></svg>`
ICON_CHEVRONS_UP_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-chevrons-up-down"><path d="m7 15 5 5 5-5"/><path d="m7 9 5-5 5 5"/></svg>`
ICON_MENU = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-menu"><line x1="4" x2="20" y1="12" y2="12"/><line x1="4" x2="20" y1="6" y2="6"/><line x1="4" x2="20" y1="18" y2="18"/></svg>`
ICON_USERS = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-users"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>`
ICON_GLOBE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-globe"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`
ICON_CODE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-code"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>`
ICON_MAIL = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail"><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`
ICON_HOUSE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>`
ICON_NEWSPAPER = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-newspaper"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"/><path d="M18 14h-8"/><path d="M15 18h-5"/><path d="M10 6h8v4h-8V6Z"/></svg>`
ICON_RSS = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rss"><path d="M4 11a9 9 0 0 1 9 9"/><path d="M4 4a16 16 0 0 1 16 16"/><circle cx="5" cy="19" r="1"/></svg>`
ICON_EYE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-eye"><path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0"/><circle cx="12" cy="12" r="3"/></svg>`
ICON_LIST_ORDERED = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-list-ordered"><path d="M10 12h11"/><path d="M10 18h11"/><path d="M10 6h11"/><path d="M4 10h2"/><path d="M4 6h1v4"/><path d="M6 18H4c0-1 2-2 2-3s-1-1.5-2-1"/></svg>`
ICON_X_DIALOG_CLOSE = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="1.4" class="w-5 h-5"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>`
ICON_PENCIL = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pencil"><path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/></svg>`
//material
ICON_SEARCH = `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="3" stroke="currentColor" class="w-8 h-8 text-slate-600"><path stroke-linecap="round" stroke-linejoin="round" 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" /></svg>`
// brand icons
// https://simpleicons.org
ICON_GO = `<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Go</title><path d="M1.811 10.231c-.047 0-.058-.023-.035-.059l.246-.315c.023-.035.081-.058.128-.058h4.172c.046 0 .058.035.035.07l-.199.303c-.023.036-.082.07-.117.07zM.047 11.306c-.047 0-.059-.023-.035-.058l.245-.316c.023-.035.082-.058.129-.058h5.328c.047 0 .07.035.058.07l-.093.28c-.012.047-.058.07-.105.07zm2.828 1.075c-.047 0-.059-.035-.035-.07l.163-.292c.023-.035.07-.07.117-.07h2.337c.047 0 .07.035.07.082l-.023.28c0 .047-.047.082-.082.082zm12.129-2.36c-.736.187-1.239.327-1.963.514-.176.046-.187.058-.34-.117-.174-.199-.303-.327-.548-.444-.737-.362-1.45-.257-2.115.175-.795.514-1.204 1.274-1.192 2.22.011.935.654 1.706 1.577 1.835.795.105 1.46-.175 1.987-.77.105-.13.198-.27.315-.434H10.47c-.245 0-.304-.152-.222-.35.152-.362.432-.97.596-1.274a.315.315 0 01.292-.187h4.253c-.023.316-.023.631-.07.947a4.983 4.983 0 01-.958 2.29c-.841 1.11-1.94 1.8-3.33 1.986-1.145.152-2.209-.07-3.143-.77-.865-.655-1.356-1.52-1.484-2.595-.152-1.274.222-2.419.993-3.424.83-1.086 1.928-1.776 3.272-2.02 1.098-.2 2.15-.07 3.096.571.62.41 1.063.97 1.356 1.648.07.105.023.164-.117.2m3.868 6.461c-1.064-.024-2.034-.328-2.852-1.029a3.665 3.665 0 01-1.262-2.255c-.21-1.32.152-2.489.947-3.529.853-1.122 1.881-1.706 3.272-1.95 1.192-.21 2.314-.095 3.33.595.923.63 1.496 1.484 1.648 2.605.198 1.578-.257 2.863-1.344 3.962-.771.783-1.718 1.273-2.805 1.495-.315.06-.63.07-.934.106zm2.78-4.72c-.011-.153-.011-.27-.034-.387-.21-1.157-1.274-1.81-2.384-1.554-1.087.245-1.788.935-2.045 2.033-.21.912.234 1.835 1.075 2.21.643.28 1.285.244 1.905-.07.923-.48 1.425-1.228 1.484-2.233z"/></svg>`
ICON_HTMX = `<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>htmx</title><path d="M0 13.01v-2l7.09-2.98.58 1.94-5.1 2.05 5.16 2.05-.63 1.9Zm16.37 1.03 5.18-2-5.16-2.09.65-1.88L24 10.95v2.12L17 16zm-2.85-9.98H16l-5.47 15.88H8.05Z"/></svg>`
ICON_X_DOT_COM = `<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>X</title><path d="M18.901 1.153h3.68l-8.04 9.19L24 22.846h-7.406l-5.8-7.584-6.638 7.584H.474l8.6-9.83L0 1.154h7.594l5.243 6.932ZM17.61 20.644h2.039L6.486 3.24H4.298Z"/></svg>`
ICON_XAI_GROK = `<svg role="img" viewBox="0 0 33 32" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>Grok</title><g><path d="M12.745 20.54l10.97-8.19c.539-.4 1.307-.244 1.564.38 1.349 3.288.746 7.241-1.938 9.955-2.683 2.714-6.417 3.31-9.83 1.954l-3.728 1.745c5.347 3.697 11.84 2.782 15.898-1.324 3.219-3.255 4.216-7.692 3.284-11.693l.008.009c-1.351-5.878.332-8.227 3.782-13.031L33 0l-4.54 4.59v-.014L12.743 20.544M10.48 22.531c-3.837-3.707-3.175-9.446.1-12.755 2.42-2.449 6.388-3.448 9.852-1.979l3.72-1.737c-.67-.49-1.53-1.017-2.515-1.387-4.455-1.854-9.789-.931-13.41 2.728-3.483 3.523-4.579 8.94-2.697 13.561 1.405 3.454-.899 5.898-3.22 8.364C1.49 30.2.666 31.074 0 32l10.478-9.466"></path></g></svg>`
ICON_GITHUB = `<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>GitHub</title><path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/></svg>`
ICON_4CH = `<svg role="img" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><title>4chan</title><path d="M11.07 8.82S9.803 1.079 5.145 1.097C2.006 1.109.78 4.124 3.055 4.802c0 0-2.698.973-2.698 2.697 0 1.725 4.274 3.54 10.713 1.32zm1.931 5.924s.904 7.791 5.558 7.991c3.136.135 4.503-2.82 2.262-3.604 0 0 2.74-.845 2.82-2.567.08-1.723-4.105-3.737-10.64-1.82zm-3.672-1.55s-7.532 2.19-6.952 6.813c.39 3.114 3.53 3.969 3.93 1.63 0 0 1.29 2.559 3.002 2.351 1.712-.208 3-4.67.02-10.794zm5.623-2.467s7.727-1.35 7.66-6.008c-.046-3.138-3.074-4.333-3.728-2.051 0 0-1-2.686-2.726-2.668-1.724.018-3.494 4.312-1.206 10.727z"/></svg>`
)
func Icon(icon string, size int) Node {
size_string := strconv.Itoa(size)
return SVG(InlineStyle("$me{display: inline;}"), Height(size_string), Width(size_string), Raw(icon))
}

11
ui/markdown.go Normal file
View File

@@ -0,0 +1,11 @@
package ui
import (
"github.com/gomarkdown/markdown"
. "maragu.dev/gomponents"
)
func Markdown(input string) Node {
html := markdown.ToHTML([]byte(input), nil, nil)
return SafeRaw(string(html))
}

105
ui/modals.go Normal file
View File

@@ -0,0 +1,105 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// DIALOG / MODAL
func ModalActuator(id string, contents Node) Node {
return Span(
InlineScriptf(`
let act = me();
let dialog = me("#%s_dialog");
act.on("click", () => { dialog.showModal(); });
`, id),
contents,
)
}
func ModalCloser() Node {
return Class("modal-close-el")
}
func Modal(id string, header Node, body Node, closeElements []Node) Node {
return Dialog(
ID(id+"_dialog"),
InlineStyle(`
$me {
top: 50%;
left: 50%;
translate: -50% -50%;
z-index: 10;
background: $color(white);
box-shadow: var(--shadow-md);
font-size: var(--text-sm);
}
$me::backdrop {
background: $color(black/30);
}
`),
// Header
Div(
InlineStyle(`
$me {
display: flex;
align-items: center;
justify-content: space-between;
padding: $6 $8 $1 $8;
font-size: var(--text-xl);
font-weight: var(--font-weight-bold);
}
`),
header,
Div(
Class("modal-close-el"),
InlineStyle("$me { cursor: pointer; }"),
Icon(ICON_X_DIALOG_CLOSE, 24),
),
),
//Modal contents
Div(
ID(id), // we use the passed id here so that swapping the content is easier
InlineStyle(`
$me {
padding: $3 $8 $8 $8;
color: $color(neutral-700);
}
`),
body,
),
// footer
If(len(closeElements) > 0,
Div(
InlineStyle(`
$me {
padding: $3 $8 $3 $8;
display: flex;
flex-direction: row-reverse;
gap: $2;
}
`),
Group(closeElements),
),
),
InlineScript(`
let dialog = me();
let close_buttons = any(".modal-close-el", me())
dialog.on("click", (ev) => {
if (ev.target === dialog) {
dialog.close();
}
});
close_buttons.on("click", () => { dialog.close(); });
`),
)
}

28
ui/quill.go Normal file
View File

@@ -0,0 +1,28 @@
package ui
import (
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// QuillJS editor components
func Quill() Node {
return Div(
InlineStyle(`
$me {
width: 100%;
background: $color(white);
min-height: $64;
}
`),
InlineScript(`
let editor = me();
let quill = new Quill(editor, {
theme: 'snow',
});
`),
)
}

88
ui/root.go Normal file
View File

@@ -0,0 +1,88 @@
package ui
import (
"maxwarden/config"
"maxwarden/security"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
func RootLayout(title string, children ...Node) Node {
// automatically invalidates cached css when file hash changes
css_hash, err := security.QuickFileHash("./wwwroot/css/style.css")
if err != nil {
return Text("Error hashing style.css")
}
metagen_css_hash, err := security.QuickFileHash("./wwwroot/css/style.metagen.css")
if err != nil {
return Text("Error hashing style.css")
}
// automatically invalidates cached js when file hash changes
js_hash, err := security.QuickFileHash("./wwwroot/js/index.js")
if err != nil {
return Text("Error hashing index.js")
}
return Doctype(
HTML(InlineStyle("$me { height: 100%; }"),
Lang("en"),
Head(
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
TitleEl(Text(title)),
Meta(Name("description"), Content("Previous")),
/////////////////////////////////
// STYLES
/////////////////////////////////
Link(Rel("apple-touch-icon"), Attr("sizes", "180x180"), Href("/apple-touch-icon.png")),
Link(Rel("icon"), Type("image/png"), Attr("sizes", "32x32"), Href("/favicon-32x32.png")),
Link(Rel("icon"), Type("image/png"), Attr("sizes", "16x16"), Href("/favicon-16x16.png")),
Link(Rel("manifest"), Href("/site.webmanifest")),
Link(Rel("stylesheet"), Href("/css/style.css?v="+css_hash)),
Link(Rel("stylesheet"), Href("/css/style.metagen.css?v="+metagen_css_hash)),
Link(Rel("stylesheet"), Href("/lib/highlight/default.min.css")),
Link(Rel("stylesheet"), Href("/lib/quill/quill.snow.css")),
/////////////////////////////////
// SCRIPTS
/////////////////////////////////
Script(Src("/js/index.js?v="+js_hash)),
// https://github.com/gnat/surreal
Script(Src("/lib/surreal/surreal.js")),
// use minified htmx only in prod
IfElse(config.DEBUG,
Script(Src("/lib/htmx/htmx.js")),
Script(Src("/lib/htmx/htmx.min.js")),
),
Script(Src("/lib/chartjs/chart.js")),
Script(Src("/lib/highlight/highlight.min.js")),
Script(Src("/lib/quill/quill.js")),
),
Group(children), // expected to provide body
),
)
}
func EmailRoot(children ...Node) Node {
return Doctype(
HTML(
Lang("en"),
Head(
Meta(Charset("utf-8")),
Meta(Name("viewport"), Content("width=device-width, initial-scale=1")),
Meta(Name("description"), Content("Previous")),
),
Body(
Group(children),
),
),
)
}

55
ui/scripts.go Normal file
View File

@@ -0,0 +1,55 @@
package ui
import (
"fmt"
. "maxwarden/basic"
"maxwarden/config"
"strings"
. "maragu.dev/gomponents"
. "maragu.dev/gomponents/html"
)
// This component generates a <script> tag that automatically includes a blank scope { }.
// Avoid using the javascript `var` keyword inside the scope as it will be placed in the global
// scope.
//
// WARNING: DO NOT PASS USER INPUT TO THIS COMPONENT!!
// THIS COULD BE A ONE WAY TICKET TO XSS IF USED IMPROPERLY!
func InlineScript(script string) Node {
// minify in production only
if !config.DEBUG {
script = strings.ReplaceAll(script, "\n", " ")
script = strings.ReplaceAll(script, "\t", "")
}
return Script(Raw(`{` + script + `}`))
}
// Wrapper function around `InlineScript` that allows for printf style format strings + data
//
// WARNING: DO NOT PASS USER INPUT TO THIS COMPONENT!!
// THIS COULD BE A ONE WAY TICKET TO XSS IF USED IMPROPERLY!
func InlineScriptf(scriptFormat string, items ...interface{}) Node {
return InlineScript(fmt.Sprintf(scriptFormat, items...))
}
// Convert Go arrays to a string containing a javascript array.
// Useful for injecting data into a dynamically generated script.
func MakeJsArray[T any](list []T) string {
var out_string string
out_string += "["
for i, v := range list {
if i == len(list)-1 {
out_string += fmt.Sprintf("'%s'", ToString(v))
} else {
out_string += fmt.Sprintf("'%s',", ToString(v))
}
}
out_string += "]"
return out_string
}

47
ui/styles.go Normal file
View File

@@ -0,0 +1,47 @@
package ui
import (
"maxwarden/basic"
"maxwarden/security"
"strings"
. "maragu.dev/gomponents"
)
// @Macro - At runtime this inserts an attribute component with the hash of the input
// At compile time, the input is collected, and used to generate a global css file.
// WARNING: THE PREPROCESSOR EXPANSION ONLY WORKS WITH *STRING LITERAL* INPUTS
// ATTEMPTING TO USE THIS WITH DYNAMIC INPUTS WILL FAIL COMPILATION
//
// For generating "dynamic" styles, use the If() / IfElse() component, and multiple calls
// to `InlineStyle`.
//
// Ex:
//
// func MyComponent(cond bool) Node {
// return IfElse(cond, InlineStyle("color: green;"), InlineStyle("color: red;"))
// }
//
// THE FOLLOWING DOES NOT WORK:
//
// func MyInvalidComponent(cond bool) Node {
// var css string
//
// if cond {
// css = "color: green;"
// } else {
// css = "color: red;"
// }
//
// return InlineStyle(css) <--- THIS IS AN ERROR! THE PREPROCESSOR HAS NO IDEA WHAT THE VALUE OF `css` IS,
// SINCE IT IS NOT KNOWN AT COMPILE TIME!
// }
func InlineStyle(input string) Node {
input = strings.ReplaceAll(input, "\n", " ")
input = strings.ReplaceAll(input, "\t", "")
s, _ := security.HighwayHash58(input)
s = basic.GetFirstNChars(s, 8)
return Attr("__inlinecss_" + s)
}