Swap Bulma for DaisyUI (Tailwind) (#111)
This commit is contained in:
parent
fc5db0e95a
commit
c1e9baabe6
53 changed files with 1124 additions and 632 deletions
|
|
@ -3,62 +3,64 @@ package components
|
|||
import (
|
||||
"github.com/mikestefanello/pagoda/pkg/msg"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui/icons"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
func FlashMessages(r *ui.Request) Node {
|
||||
var g Group
|
||||
var color Color
|
||||
|
||||
for _, typ := range []msg.Type{
|
||||
msg.TypeSuccess,
|
||||
msg.TypeInfo,
|
||||
msg.TypeWarning,
|
||||
msg.TypeDanger,
|
||||
msg.TypeError,
|
||||
} {
|
||||
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
|
||||
}
|
||||
|
||||
func Notification(typ msg.Type, text string) Node {
|
||||
func Alert(color Color, text string) Node {
|
||||
var class string
|
||||
|
||||
switch typ {
|
||||
case msg.TypeSuccess:
|
||||
class = "success"
|
||||
case msg.TypeInfo:
|
||||
class = "info"
|
||||
case msg.TypeWarning:
|
||||
class = "warning"
|
||||
case msg.TypeDanger:
|
||||
class = "danger"
|
||||
switch color {
|
||||
case ColorSuccess:
|
||||
class = "alert-success"
|
||||
case ColorInfo:
|
||||
class = "alert-info"
|
||||
case ColorWarning:
|
||||
class = "alert-warning"
|
||||
case ColorError:
|
||||
class = "alert-error"
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("notification is-"+class),
|
||||
Role("alert"),
|
||||
Class("alert mb-2 "+class),
|
||||
Attr("x-data", "{show: true}"),
|
||||
Attr("x-show", "show"),
|
||||
Button(
|
||||
Class("delete"),
|
||||
Span(
|
||||
Attr("@click", "show = false"),
|
||||
Class("cursor-pointer"),
|
||||
icons.XCircle(),
|
||||
),
|
||||
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,
|
||||
),
|
||||
Span(Text(text)),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
FileFieldParams struct {
|
||||
Name string
|
||||
Label string
|
||||
Help string
|
||||
}
|
||||
|
||||
OptionsParams struct {
|
||||
Form form.Form
|
||||
FormField string
|
||||
|
|
@ -26,6 +32,7 @@ type (
|
|||
Label string
|
||||
Value string
|
||||
Options []Choice
|
||||
Help string
|
||||
}
|
||||
|
||||
Choice struct {
|
||||
|
|
@ -52,38 +59,22 @@ type (
|
|||
)
|
||||
|
||||
func ControlGroup(controls ...Node) Node {
|
||||
g := make(Group, len(controls))
|
||||
for i, control := range controls {
|
||||
g[i] = Div(
|
||||
Class("control"),
|
||||
control,
|
||||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("field is-grouped"),
|
||||
g,
|
||||
Class("mt-2 flex gap-2"),
|
||||
Group(controls),
|
||||
)
|
||||
}
|
||||
|
||||
func TextareaField(el TextareaFieldParams) Node {
|
||||
return Div(
|
||||
Class("field"),
|
||||
Label(
|
||||
For("name"),
|
||||
Class("label"),
|
||||
Text(el.Label),
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Textarea(
|
||||
Class("textarea h-24 w-2/3 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Text(el.Value),
|
||||
),
|
||||
Div(
|
||||
Class("control"),
|
||||
Textarea(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Class("textarea "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Text(el.Value),
|
||||
),
|
||||
),
|
||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
|
@ -91,25 +82,27 @@ func TextareaField(el TextareaFieldParams) Node {
|
|||
func Radios(el OptionsParams) Node {
|
||||
buttons := make(Group, len(el.Options))
|
||||
for i, opt := range el.Options {
|
||||
buttons[i] = Label(
|
||||
Class("radio"),
|
||||
id := "radio-" + el.Name + "-" + opt.Value
|
||||
buttons[i] = Div(
|
||||
Class("mb-2"),
|
||||
Input(
|
||||
ID(id),
|
||||
Type("radio"),
|
||||
Name(el.Name),
|
||||
Value(opt.Value),
|
||||
Class("radio mr-1 "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
If(el.Value == opt.Value, Checked()),
|
||||
),
|
||||
Text(" "+opt.Label),
|
||||
Label(
|
||||
Text(opt.Label),
|
||||
For(id),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("control field"),
|
||||
Label(Class("label"), Text(el.Label)),
|
||||
Div(
|
||||
Class("radios"),
|
||||
buttons,
|
||||
),
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
buttons,
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
|
@ -124,82 +117,77 @@ func SelectList(el OptionsParams) Node {
|
|||
)
|
||||
}
|
||||
|
||||
return Div(
|
||||
Class("control field"),
|
||||
Label(Class("label"), Text(el.Label)),
|
||||
Div(
|
||||
Class("select"),
|
||||
Select(
|
||||
Name(el.Name),
|
||||
buttons,
|
||||
),
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Select(
|
||||
Class("select "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
buttons,
|
||||
),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func Checkbox(el CheckboxParams) Node {
|
||||
return Div(
|
||||
Class("field"),
|
||||
Div(
|
||||
Class("control"),
|
||||
Label(
|
||||
Label(
|
||||
Class("label"),
|
||||
Input(
|
||||
Class("checkbox"),
|
||||
Input(
|
||||
Type("checkbox"),
|
||||
Name(el.Name),
|
||||
If(el.Checked, Checked()),
|
||||
Value("true"),
|
||||
),
|
||||
Text(" "+el.Label),
|
||||
Type("checkbox"),
|
||||
Name(el.Name),
|
||||
If(el.Checked, Checked()),
|
||||
Value("true"),
|
||||
),
|
||||
Text(" "+el.Label),
|
||||
),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func InputField(el InputFieldParams) Node {
|
||||
return Div(
|
||||
Class("field"),
|
||||
Label(
|
||||
Class("label"),
|
||||
For(el.Name),
|
||||
Text(el.Label),
|
||||
return Fieldset(
|
||||
el.Label,
|
||||
Input(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Type(el.InputType),
|
||||
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Value(el.Value),
|
||||
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
||||
),
|
||||
Div(
|
||||
Class("control"),
|
||||
Input(
|
||||
ID(el.Name),
|
||||
Name(el.Name),
|
||||
Type(el.InputType),
|
||||
If(el.Placeholder != "", Placeholder(el.Placeholder)),
|
||||
Class("input "+formFieldStatusClass(el.Form, el.FormField)),
|
||||
Value(el.Value),
|
||||
),
|
||||
),
|
||||
If(el.Help != "", P(Class("help"), Text(el.Help))),
|
||||
Help(el.Help),
|
||||
formFieldErrors(el.Form, el.FormField),
|
||||
)
|
||||
}
|
||||
|
||||
func FileField(name, label string) Node {
|
||||
return Div(
|
||||
Class("field file"),
|
||||
Label(
|
||||
Class("file-label"),
|
||||
Input(
|
||||
Class("file-input"),
|
||||
Type("file"),
|
||||
Name(name),
|
||||
),
|
||||
Span(
|
||||
Class("file-cta"),
|
||||
Span(
|
||||
Class("file-label"),
|
||||
Text(label),
|
||||
),
|
||||
),
|
||||
func Help(text string) Node {
|
||||
return If(len(text) > 0, Div(
|
||||
Class("label"),
|
||||
Text(text),
|
||||
))
|
||||
}
|
||||
|
||||
func Fieldset(label string, els ...Node) Node {
|
||||
return FieldSet(
|
||||
Class("fieldset"),
|
||||
If(len(label) > 0, Legend(
|
||||
Class("fieldset-legend"),
|
||||
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():
|
||||
return ""
|
||||
case fm.FieldHasErrors(formField):
|
||||
return "is-danger"
|
||||
return "input-error"
|
||||
default:
|
||||
return "is-success"
|
||||
return "input-success"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -228,8 +216,8 @@ func formFieldErrors(fm form.Form, field string) Node {
|
|||
|
||||
g := make(Group, len(errs))
|
||||
for i, err := range errs {
|
||||
g[i] = P(
|
||||
Class("help is-danger"),
|
||||
g[i] = Div(
|
||||
Class("text-error"),
|
||||
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(
|
||||
Class("button "+class),
|
||||
Class("btn "+buttonColor(color)),
|
||||
Text(label),
|
||||
)
|
||||
}
|
||||
|
||||
func ButtonLink(href, class, label string) Node {
|
||||
func ButtonLink(color Color, href, label string) Node {
|
||||
return A(
|
||||
Href(href),
|
||||
Class("button "+class),
|
||||
Class("btn "+buttonColor(color)),
|
||||
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"
|
||||
)
|
||||
|
||||
func JS(r *ui.Request) Node {
|
||||
func JS() Node {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
func CSS() Node {
|
||||
return Link(
|
||||
Href("https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css"),
|
||||
Href(ui.StaticFile("main.css")),
|
||||
Rel("stylesheet"),
|
||||
Type("text/css"),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +27,7 @@ func Metatags(r *ui.Request) Node {
|
|||
return Group{
|
||||
Meta(Charset("utf-8")),
|
||||
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))),
|
||||
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, ", ")))),
|
||||
|
|
|
|||
|
|
@ -1,19 +1,72 @@
|
|||
package components
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/mikestefanello/pagoda/pkg/pager"
|
||||
"github.com/mikestefanello/pagoda/pkg/ui"
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/components"
|
||||
. "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...)
|
||||
|
||||
return Li(
|
||||
Class("ml-2"),
|
||||
A(
|
||||
Href(href),
|
||||
icon,
|
||||
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 (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
|
||||
. "maragu.dev/gomponents"
|
||||
. "maragu.dev/gomponents/html"
|
||||
|
|
@ -11,46 +12,27 @@ type Tab struct {
|
|||
Title, Body string
|
||||
}
|
||||
|
||||
func Tabs(heading, description string, items []Tab) Node {
|
||||
renderTitles := func() Node {
|
||||
g := make(Group, len(items))
|
||||
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
|
||||
}
|
||||
func Tabs(tabs []Tab) Node {
|
||||
g := make(Group, 0, len(tabs)*2)
|
||||
id := fmt.Sprintf("tabs-%d", rand.Int())
|
||||
|
||||
renderBodies := func() Node {
|
||||
g := make(Group, len(items))
|
||||
for i, item := range items {
|
||||
g[i] = Div(
|
||||
Attr("x-show", fmt.Sprintf("tab == %d", i)),
|
||||
P(Raw(" "+item.Body)),
|
||||
)
|
||||
}
|
||||
return g
|
||||
for i, tab := range tabs {
|
||||
g = append(g,
|
||||
Input(
|
||||
Type("radio"),
|
||||
Name(id),
|
||||
Class("tab"),
|
||||
Aria("label", tab.Title),
|
||||
If(i == 0, Checked()),
|
||||
),
|
||||
Div(
|
||||
Class("tab-content bg-base-100 border-base-300 p-6"),
|
||||
Raw(tab.Body),
|
||||
))
|
||||
}
|
||||
|
||||
return Div(
|
||||
P(
|
||||
Class("subtitle mt-5"),
|
||||
Text(heading),
|
||||
),
|
||||
P(
|
||||
Class("mb-4"),
|
||||
Text(description),
|
||||
),
|
||||
Div(
|
||||
Attr("x-data", "{tab: 0}"),
|
||||
Div(
|
||||
Class("tabs"),
|
||||
Ul(renderTitles()),
|
||||
),
|
||||
renderBodies(),
|
||||
),
|
||||
Class("tabs tabs-lift"),
|
||||
g,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue