wip
This commit is contained in:
134
web/components/document/actions.go
Normal file
134
web/components/document/actions.go
Normal file
@@ -0,0 +1,134 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func Actions(d models.Document) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col float-left gap-2 w-44 md:w-60 lg:w-80 mr-4 relative"),
|
||||
|
||||
// Cover
|
||||
ui.AnchoredPopover(
|
||||
h.Img(
|
||||
h.Class("rounded object-fill w-full"),
|
||||
h.Src(fmt.Sprintf("/documents/%s/cover", d.ID)),
|
||||
),
|
||||
editCoverPopover(d.ID),
|
||||
),
|
||||
|
||||
// Read
|
||||
ui.LinkButton(g.Text("Read"), fmt.Sprintf("/reader#id=%s&type=REMOTE", d.ID)),
|
||||
|
||||
// Actions
|
||||
h.Div(
|
||||
h.Class("flex flex-col justify-between z-20 gap-2 relative"),
|
||||
|
||||
h.Div(
|
||||
h.Class("flex grow align-center justify-between my-auto text-gray-500 dark:text-gray-500"),
|
||||
|
||||
ui.AnchoredPopover(
|
||||
ui.SpanButton(assets.Icon("delete", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
deletePopover(d.ID),
|
||||
),
|
||||
|
||||
ui.LinkButton(
|
||||
assets.Icon("activity", 28),
|
||||
fmt.Sprintf("../activity?document=%s", d.ID),
|
||||
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
|
||||
),
|
||||
|
||||
ui.AnchoredPopover(
|
||||
ui.SpanButton(assets.Icon("search", 28), ui.ButtonConfig{Variant: ui.ButtonVariantGhost}),
|
||||
searchPopover(d),
|
||||
),
|
||||
|
||||
ui.LinkButton(
|
||||
assets.Icon("download", 28),
|
||||
fmt.Sprintf("./%s/file", d.ID),
|
||||
ui.ButtonConfig{
|
||||
Variant: ui.ButtonVariantGhost,
|
||||
Disabled: !d.HasFile,
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func editCoverPopover(docID string) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.Form(
|
||||
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
g.Attr("enctype", "multipart/form-data"),
|
||||
h.Action(fmt.Sprintf("./%s/edit", docID)),
|
||||
h.Input(h.Type("file"), h.ID("cover_file"), h.Name("cover_file")),
|
||||
ui.FormButton(g.Text("Upload Cover"), ""),
|
||||
),
|
||||
h.Form(
|
||||
h.Class("flex flex-col gap-2 w-[19rem] text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/edit", docID)),
|
||||
h.Input(
|
||||
h.ID("remove_cover"),
|
||||
h.Name("remove_cover"),
|
||||
h.Class("hidden"),
|
||||
h.Type("checkbox"),
|
||||
h.Checked(),
|
||||
),
|
||||
ui.FormButton(g.Text("Remove Cover"), ""),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func deletePopover(id string) g.Node {
|
||||
return h.Form(
|
||||
h.Class("text-black dark:text-white text-sm w-24"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/delete", id)),
|
||||
ui.FormButton(g.Text("Delete"), ""),
|
||||
)
|
||||
}
|
||||
|
||||
func searchPopover(d models.Document) g.Node {
|
||||
return h.Form(
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("./%s/identify", d.ID)),
|
||||
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
|
||||
h.Input(
|
||||
h.ID("title"),
|
||||
h.Name("title"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Title"),
|
||||
h.Value(d.Title),
|
||||
),
|
||||
h.Input(
|
||||
h.ID("author"),
|
||||
h.Name("author"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("Author"),
|
||||
h.Value(d.Author),
|
||||
),
|
||||
h.Input(
|
||||
h.ID("isbn"),
|
||||
h.Name("isbn"),
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.Placeholder("ISBN 10 / ISBN 13"),
|
||||
h.Value(utils.FirstNonZero(d.ISBN13, d.ISBN10)),
|
||||
),
|
||||
ui.FormButton(g.Text("Identify"), ""),
|
||||
)
|
||||
}
|
||||
54
web/components/document/card.go
Normal file
54
web/components/document/card.go
Normal file
@@ -0,0 +1,54 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/formatters"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func Card(d models.Document) g.Node {
|
||||
return h.Div(
|
||||
h.Class("w-full relative"),
|
||||
h.Div(
|
||||
h.Class("flex gap-4 w-full h-full p-4 shadow-lg bg-white dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
h.Class("min-w-fit my-auto h-48 relative"),
|
||||
h.A(
|
||||
h.Href("./documents/"+d.ID),
|
||||
h.Img(
|
||||
h.Src("./documents/"+d.ID+"/cover"),
|
||||
h.Class("rounded object-cover h-full"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex flex-col justify-around dark:text-white w-full text-sm"),
|
||||
ui.KeyValue(g.Text("Title"), g.Text(d.Title)),
|
||||
ui.KeyValue(g.Text("Author"), g.Text(d.Author)),
|
||||
ui.KeyValue(g.Text("Progress"), g.Text(fmt.Sprintf("%.2f%%", d.Percentage))),
|
||||
ui.KeyValue(g.Text("Time Read"), g.Text(formatters.FormatDuration(d.TotalTimeRead))),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("absolute flex flex-col gap-2 right-4 bottom-4 text-gray-500 dark:text-gray-400"),
|
||||
ui.LinkButton(
|
||||
assets.Icon("activity", 24),
|
||||
"./activity?document="+d.ID,
|
||||
ui.ButtonConfig{Variant: ui.ButtonVariantGhost},
|
||||
),
|
||||
ui.LinkButton(
|
||||
assets.Icon("download", 24),
|
||||
"./documents/"+d.ID+"/file",
|
||||
ui.ButtonConfig{
|
||||
Variant: ui.ButtonVariantGhost,
|
||||
Disabled: !d.HasFile,
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
104
web/components/document/identify_popover.go
Normal file
104
web/components/document/identify_popover.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package document
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
"reichard.io/antholume/web/models"
|
||||
)
|
||||
|
||||
func IdentifyPopover(docID string, m *models.DocumentMetadata) g.Node {
|
||||
if m == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if m.Error != nil {
|
||||
return ui.Popover(h.Div(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.H3(
|
||||
h.Class("text-lg font-bold text-center"),
|
||||
g.Text("Error"),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("bg-gray-100 dark:bg-gray-900 p-2"),
|
||||
h.P(g.Text(*m.Error)),
|
||||
),
|
||||
ui.LinkButton(g.Text("Back to Document"), fmt.Sprintf("/documents/%s", docID)),
|
||||
))
|
||||
}
|
||||
|
||||
return ui.Popover(h.Div(
|
||||
h.Class("flex flex-col gap-2"),
|
||||
h.H3(
|
||||
h.Class("text-lg font-bold text-center"),
|
||||
g.Text("Metadata Results"),
|
||||
),
|
||||
h.Form(
|
||||
h.ID("metadata-save"),
|
||||
h.Method("POST"),
|
||||
h.Action(fmt.Sprintf("/documents/%s/edit", docID)),
|
||||
h.Class("text-black dark:text-white border-b dark:border-black"),
|
||||
h.Dl(
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Cover")),
|
||||
h.Dd(
|
||||
h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"),
|
||||
h.Img(
|
||||
h.Class("rounded object-fill h-32"),
|
||||
h.Src(fmt.Sprintf("https://books.google.com/books/content/images/frontcover/%s?fife=w480-h690", m.SourceID)),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Title")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Title, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Author")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.Author, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 10")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN10, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-gray-100 dark:bg-gray-900 grid grid-cols-3 gap-4 sm:px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("ISBN 13")),
|
||||
h.Dd(h.Class("mt-1 text-sm sm:mt-0 sm:col-span-2"), g.Text(utils.FirstNonZero(m.ISBN13, "N/A"))),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("p-3 bg-white dark:bg-gray-800 sm:grid sm:grid-cols-3 sm:gap-4 px-6"),
|
||||
h.Dt(h.Class("my-auto font-medium text-gray-500"), g.Text("Description")),
|
||||
h.Dd(
|
||||
h.Class("max-h-[10em] overflow-scroll mt-1 sm:mt-0 sm:col-span-2"),
|
||||
g.Text(utils.FirstNonZero(m.Description, "N/A")),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("hidden"),
|
||||
h.Input(h.Type("text"), h.ID("title"), h.Name("title"), h.Value(m.Title)),
|
||||
h.Input(h.Type("text"), h.ID("author"), h.Name("author"), h.Value(m.Author)),
|
||||
h.Input(h.Type("text"), h.ID("description"), h.Name("description"), h.Value(m.Description)),
|
||||
h.Input(h.Type("text"), h.ID("isbn_10"), h.Name("isbn_10"), h.Value(m.ISBN10)),
|
||||
h.Input(h.Type("text"), h.ID("isbn_13"), h.Name("isbn_13"), h.Value(m.ISBN13)),
|
||||
h.Input(h.Type("text"), h.ID("cover_gbid"), h.Name("cover_gbid"), h.Value(m.SourceID)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
h.Class("flex justify-end"),
|
||||
h.Div(
|
||||
h.Class("flex gap-4 w-48"),
|
||||
ui.LinkButton(g.Text("Cancel"), fmt.Sprintf("/documents/%s", docID)),
|
||||
ui.FormButton(g.Text("Save"), "metadata-save"),
|
||||
),
|
||||
),
|
||||
))
|
||||
}
|
||||
23
web/components/forms/edit.go
Normal file
23
web/components/forms/edit.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package forms
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/components/ui"
|
||||
)
|
||||
|
||||
func Edit(key, val, url string) g.Node {
|
||||
return h.Form(
|
||||
h.Class("flex flex-col gap-2 text-black dark:text-white text-sm"),
|
||||
h.Method("POST"),
|
||||
h.Action(url),
|
||||
h.Input(
|
||||
h.Class("p-2 bg-gray-300 text-black dark:bg-gray-700 dark:text-white"),
|
||||
h.Type("text"),
|
||||
h.ID(key),
|
||||
h.Name(key),
|
||||
h.Value(val),
|
||||
),
|
||||
ui.FormButton(g.Text("Save"), ""),
|
||||
)
|
||||
}
|
||||
57
web/components/layout/layout.go
Normal file
57
web/components/layout/layout.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/pages"
|
||||
)
|
||||
|
||||
type LayoutOptions struct {
|
||||
SearchEnabled bool
|
||||
IsAdmin bool
|
||||
Username string
|
||||
Version string
|
||||
}
|
||||
|
||||
func Layout(p pages.Page, opts LayoutOptions) g.Node {
|
||||
return h.Doctype(
|
||||
h.HTML(
|
||||
g.Attr("lang", "en"),
|
||||
Head(p.Route().Title()),
|
||||
h.Body(
|
||||
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
|
||||
Navigation(p.Route(), &opts),
|
||||
Base(p.Render()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func Head(routeTitle string) g.Node {
|
||||
return h.Head(
|
||||
h.Title("AnthoLume - "+routeTitle),
|
||||
h.Meta(g.Attr("charset", "utf-8")),
|
||||
h.Meta(g.Attr("name", "viewport"), g.Attr("content", "width=device-width, initial-scale=0.9, user-scalable=no, viewport-fit=cover")),
|
||||
h.Meta(g.Attr("name", "apple-mobile-web-app-capable"), g.Attr("content", "yes")),
|
||||
h.Meta(g.Attr("name", "apple-mobile-web-app-status-bar-style"), g.Attr("content", "black-translucent")),
|
||||
h.Meta(g.Attr("name", "theme-color"), g.Attr("content", "#F3F4F6"), g.Attr("media", "(prefers-color-scheme: light)")),
|
||||
h.Meta(g.Attr("name", "theme-color"), g.Attr("content", "#1F2937"), g.Attr("media", "(prefers-color-scheme: dark)")),
|
||||
h.Link(g.Attr("rel", "manifest"), g.Attr("href", "/manifest.json")),
|
||||
h.Link(g.Attr("rel", "stylesheet"), g.Attr("href", "/assets/index.css")),
|
||||
h.Link(g.Attr("rel", "stylesheet"), g.Attr("href", "/assets/tailwind.css")),
|
||||
h.Script(g.Attr("src", "/assets/lib/idb-keyval.min.js")),
|
||||
h.Script(g.Attr("src", "/assets/common.js")),
|
||||
h.Script(g.Attr("src", "/assets/index.js")),
|
||||
)
|
||||
}
|
||||
|
||||
func Base(body g.Node) g.Node {
|
||||
return h.Main(
|
||||
g.Attr("class", "relative overflow-hidden"),
|
||||
h.Div(
|
||||
g.Attr("id", "container"),
|
||||
g.Attr("class", "h-[100dvh] px-4 overflow-auto md:px-6 lg:ml-48"),
|
||||
body,
|
||||
),
|
||||
)
|
||||
}
|
||||
168
web/components/layout/navigation.go
Normal file
168
web/components/layout/navigation.go
Normal file
@@ -0,0 +1,168 @@
|
||||
package layout
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/web/assets"
|
||||
"reichard.io/antholume/web/pages"
|
||||
)
|
||||
|
||||
const (
|
||||
active = "border-purple-500 dark:text-white"
|
||||
inactive = "border-transparent text-gray-400 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
)
|
||||
|
||||
func Navigation(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "flex items-center justify-between w-full h-16"),
|
||||
Sidebar(currentRoute, opts),
|
||||
h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(currentRoute.Title())),
|
||||
Dropdown(opts.Username),
|
||||
)
|
||||
}
|
||||
|
||||
func Sidebar(currentRoute pages.PageRoute, opts *LayoutOptions) g.Node {
|
||||
links := []g.Node{
|
||||
navLink(currentRoute, pages.HomePage, "/", "home"),
|
||||
navLink(currentRoute, pages.DocumentsPage, "/documents", "documents"),
|
||||
navLink(currentRoute, pages.ProgressPage, "/progress", "activity"),
|
||||
navLink(currentRoute, pages.ActivityPage, "/activity", "activity"),
|
||||
}
|
||||
if opts.SearchEnabled {
|
||||
links = append(links, navLink(currentRoute, pages.SearchPage, "/search", "search"))
|
||||
}
|
||||
if opts.IsAdmin {
|
||||
links = append(links, adminLinks(currentRoute))
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
g.Attr("id", "mobile-nav-button"),
|
||||
g.Attr("class", "flex flex-col z-40 relative ml-6"),
|
||||
hamburgerIcon(),
|
||||
h.Div(
|
||||
g.Attr("id", "menu"),
|
||||
g.Attr("class", "fixed -ml-6 h-full w-56 lg:w-48 bg-white dark:bg-gray-700 shadow-lg"),
|
||||
h.Div(
|
||||
g.Attr("class", "h-16 flex justify-end lg:justify-around"),
|
||||
h.P(g.Attr("class", "text-xl font-bold text-right my-auto pr-8 lg:pr-0"), g.Text("AnthoLume")),
|
||||
),
|
||||
h.Div(links...),
|
||||
h.A(
|
||||
g.Attr("href", "https://gitea.va.reichard.io/evan/AnthoLume"),
|
||||
g.Attr("target", "_blank"),
|
||||
g.Attr("class", "flex flex-col gap-2 justify-center items-center p-6 w-full absolute bottom-0 text-black dark:text-white"),
|
||||
assets.Icon("gitea", 20),
|
||||
h.Span(g.Attr("class", "text-xs"), g.Text(opts.Version)),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func navLink(currentRoute, linkRoute pages.PageRoute, path, icon string) g.Node {
|
||||
class := inactive
|
||||
if currentRoute == linkRoute {
|
||||
class = active
|
||||
}
|
||||
return h.A(
|
||||
g.Attr("class", "flex items-center justify-start w-full p-2 pl-6 my-2 transition-colors duration-200 border-l-4 "+class),
|
||||
h.Href(path),
|
||||
assets.Icon(icon, 20),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text(linkRoute.Title())),
|
||||
)
|
||||
}
|
||||
|
||||
func adminLinks(currentRoute pages.PageRoute) g.Node {
|
||||
routeID := string(currentRoute)
|
||||
|
||||
class := inactive
|
||||
if strings.HasPrefix(routeID, "admin") {
|
||||
class = active
|
||||
}
|
||||
|
||||
children := g.If(strings.HasPrefix(routeID, "admin"),
|
||||
g.Group([]g.Node{
|
||||
subNavLink(currentRoute, pages.AdminGeneralPage, "/admin"),
|
||||
subNavLink(currentRoute, pages.AdminImportPage, "/admin/import"),
|
||||
subNavLink(currentRoute, pages.AdminUsersPage, "/admin/users"),
|
||||
subNavLink(currentRoute, pages.AdminLogsPage, "/admin/logs"),
|
||||
}),
|
||||
)
|
||||
|
||||
return h.Div(
|
||||
g.Attr("class", "flex flex-col gap-4 p-2 pl-6 my-2 transition-colors duration-200 border-l-4 "+class),
|
||||
h.A(
|
||||
g.Attr("href", "/admin"),
|
||||
g.Attr("class", "flex justify-start w-full"),
|
||||
assets.Icon("settings", 20),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text("Admin")),
|
||||
),
|
||||
children,
|
||||
)
|
||||
}
|
||||
|
||||
func subNavLink(currentRoute, linkRoute pages.PageRoute, path string) g.Node {
|
||||
class := inactive
|
||||
if currentRoute == linkRoute {
|
||||
class = active
|
||||
}
|
||||
|
||||
pageTitle := linkRoute.Title()
|
||||
if splitString := strings.Split(pageTitle, " - "); len(splitString) > 1 {
|
||||
pageTitle = splitString[1]
|
||||
}
|
||||
|
||||
return h.A(
|
||||
g.Attr("class", class),
|
||||
g.Attr("href", path),
|
||||
g.Attr("style", "padding-left:1.75em"),
|
||||
h.Span(g.Attr("class", "mx-4 text-sm font-normal"), g.Text(pageTitle)),
|
||||
)
|
||||
}
|
||||
|
||||
func hamburgerIcon() g.Node {
|
||||
return g.Group([]g.Node{
|
||||
h.Input(g.Attr("type", "checkbox"), g.Attr("class", "absolute lg:hidden z-50 -top-2 w-7 h-7 opacity-0 cursor-pointer")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-0.5")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-1")),
|
||||
h.Span(g.Attr("class", "lg:hidden bg-black dark:bg-white w-7 h-0.5 z-40 mt-1")),
|
||||
})
|
||||
}
|
||||
|
||||
func Dropdown(username string) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "relative flex items-center justify-end w-full p-4"),
|
||||
h.Input(g.Attr("type", "checkbox"), g.Attr("id", "user-dropdown-button"), g.Attr("class", "hidden")),
|
||||
h.Div(
|
||||
g.Attr("id", "user-dropdown"),
|
||||
g.Attr("class", "transition duration-200 z-20 absolute right-4 top-16 pt-4"),
|
||||
h.Div(
|
||||
g.Attr("class", "w-40 origin-top-right bg-white rounded-md shadow-lg dark:shadow-gray-800 dark:bg-gray-700 ring-1 ring-black ring-opacity-5"),
|
||||
h.Div(
|
||||
g.Attr("class", "py-1"),
|
||||
dropdownItem("/settings", "Settings"),
|
||||
dropdownItem("/local", "Offline"),
|
||||
dropdownItem("/logout", "Logout"),
|
||||
),
|
||||
),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", "user-dropdown-button"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center gap-2 text-md py-4 cursor-pointer"),
|
||||
assets.Icon("user", 20),
|
||||
h.Span(g.Text(username)),
|
||||
assets.Icon("dropdown", 20),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func dropdownItem(href, text string) g.Node {
|
||||
return h.A(
|
||||
g.Attr("href", href),
|
||||
g.Attr("class", "block px-4 py-2 text-md text-gray-700 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600"),
|
||||
g.Text(text),
|
||||
)
|
||||
}
|
||||
35
web/components/stats/info_card.go
Normal file
35
web/components/stats/info_card.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type InfoCardData struct {
|
||||
Title string
|
||||
Size int64
|
||||
Link string
|
||||
}
|
||||
|
||||
func InfoCard(d InfoCardData) g.Node {
|
||||
cardContent := h.Div(
|
||||
g.Attr("class", "flex gap-4 w-full p-4 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col justify-around w-full text-sm"),
|
||||
h.P(g.Attr("class", "text-2xl font-bold"), g.Text(fmt.Sprint(d.Size))),
|
||||
h.P(g.Attr("class", "text-sm text-gray-400"), g.Text(d.Title)),
|
||||
),
|
||||
)
|
||||
|
||||
if d.Link == "" {
|
||||
return h.Div(g.Attr("class", "w-full"), cardContent)
|
||||
}
|
||||
|
||||
return h.A(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Href(d.Link),
|
||||
cardContent,
|
||||
)
|
||||
}
|
||||
130
web/components/stats/leaderboard_card.go
Normal file
130
web/components/stats/leaderboard_card.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type LeaderboardItem struct {
|
||||
UserID string
|
||||
Value string
|
||||
}
|
||||
|
||||
type LeaderboardData struct {
|
||||
Name string
|
||||
All []LeaderboardItem
|
||||
Year []LeaderboardItem
|
||||
Month []LeaderboardItem
|
||||
Week []LeaderboardItem
|
||||
}
|
||||
|
||||
func LeaderboardCard(l LeaderboardData) g.Node {
|
||||
orderedItems := map[string][]LeaderboardItem{
|
||||
"All": l.All,
|
||||
"Year": l.Year,
|
||||
"Month": l.Month,
|
||||
"Week": l.Week,
|
||||
}
|
||||
|
||||
var allNodes []g.Node
|
||||
for key, items := range orderedItems {
|
||||
// Get Top Reader Nodes
|
||||
topReaders := items[:min(len(items), 3)]
|
||||
var topReaderNodes []g.Node
|
||||
for idx, reader := range topReaders {
|
||||
border := ""
|
||||
if idx > 0 {
|
||||
border = " border-t border-gray-200"
|
||||
}
|
||||
topReaderNodes = append(topReaderNodes, h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pt-2 pb-2 text-sm"+border),
|
||||
h.Div(h.P(g.Text(reader.UserID))),
|
||||
h.Div(g.Attr("class", "flex items-end font-bold"), g.Text(reader.Value)),
|
||||
))
|
||||
}
|
||||
|
||||
allNodes = append(allNodes, g.Group([]g.Node{
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end my-6 space-x-2 hidden peer-checked/"+key+":block"),
|
||||
g.If(len(items) == 0,
|
||||
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text("N/A")),
|
||||
),
|
||||
g.If(len(items) > 0,
|
||||
h.P(g.Attr("class", "text-5xl font-bold text-black dark:text-white"), g.Text(items[0].UserID)),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "hidden dark:text-white peer-checked/"+key+":block"),
|
||||
g.Group(topReaderNodes),
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col justify-between h-full w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.Div(
|
||||
h.Div(
|
||||
g.Attr("class", "flex justify-between"),
|
||||
h.P(
|
||||
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
|
||||
g.Textf("%s Leaderboard", l.Name),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex gap-2 text-xs text-gray-400 items-center"),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("all-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("all"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("year-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("year"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("month-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("month"),
|
||||
),
|
||||
h.Label(
|
||||
g.Attr("for", fmt.Sprintf("week-%s", l.Name)),
|
||||
g.Attr("class", "cursor-pointer hover:text-black dark:hover:text-white"),
|
||||
g.Text("week"),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("all-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/All"),
|
||||
g.Attr("checked", ""),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("year-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Year"),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("month-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Month"),
|
||||
),
|
||||
h.Input(
|
||||
g.Attr("type", "radio"),
|
||||
g.Attr("name", fmt.Sprintf("options-%s", l.Name)),
|
||||
g.Attr("id", fmt.Sprintf("week-%s", l.Name)),
|
||||
g.Attr("class", "hidden peer/Week"),
|
||||
),
|
||||
g.Group(allNodes),
|
||||
),
|
||||
)
|
||||
}
|
||||
61
web/components/stats/monthly_chart.go
Normal file
61
web/components/stats/monthly_chart.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/graph"
|
||||
)
|
||||
|
||||
func MonthlyChart(dailyStats []database.GetDailyReadStatsRow) g.Node {
|
||||
graphData := buildSVGGraphData(dailyStats, 800, 70)
|
||||
return h.Div(
|
||||
g.Attr("class", "relative"),
|
||||
h.SVG(
|
||||
g.Attr("viewBox", fmt.Sprintf("26 0 755 %d", graphData.Height)),
|
||||
g.Attr("preserveAspectRatio", "none"),
|
||||
g.Attr("width", "100%"),
|
||||
g.Attr("height", "6em"),
|
||||
g.El("path",
|
||||
g.Attr("fill", "#316BBE"),
|
||||
g.Attr("fill-opacity", "0.5"),
|
||||
g.Attr("stroke", "none"),
|
||||
g.Attr("d", graphData.BezierPath+" "+graphData.BezierFill),
|
||||
),
|
||||
g.El("path",
|
||||
g.Attr("fill", "none"),
|
||||
g.Attr("stroke", "#316BBE"),
|
||||
g.Attr("d", graphData.BezierPath),
|
||||
),
|
||||
),
|
||||
|
||||
h.Div(
|
||||
g.Attr("class", "flex absolute w-full h-full top-0"),
|
||||
g.Attr("style", "width: calc(100%*31/30); transform: translateX(-50%); left: 50%"),
|
||||
g.Group(g.Map(dailyStats, func(d database.GetDailyReadStatsRow) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("onclick", ""),
|
||||
g.Attr("class", "opacity-0 hover:opacity-100 w-full"),
|
||||
g.Attr("style", "background: linear-gradient(rgba(128, 128, 128, 0.5), rgba(128, 128, 128, 0.5)) no-repeat center/2px 100%"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex flex-col items-center p-2 rounded absolute top-3 dark:text-white text-xs pointer-events-none"),
|
||||
g.Attr("style", "transform: translateX(-50%); background-color: rgba(128, 128, 128, 0.2); left: 50%"),
|
||||
h.Span(g.Text(d.Date)),
|
||||
h.Span(g.Textf("%d minutes", d.MinutesRead)),
|
||||
),
|
||||
)
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// buildSVGGraphData builds SVGGraphData from the provided stats, width and height.
|
||||
func buildSVGGraphData(inputData []database.GetDailyReadStatsRow, svgWidth int, svgHeight int) graph.SVGGraphData {
|
||||
var intData []int64
|
||||
for _, item := range inputData {
|
||||
intData = append(intData, item.MinutesRead)
|
||||
}
|
||||
return graph.GetSVGGraphData(intData, svgWidth, svgHeight)
|
||||
}
|
||||
65
web/components/stats/streak_card.go
Normal file
65
web/components/stats/streak_card.go
Normal file
@@ -0,0 +1,65 @@
|
||||
package stats
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/database"
|
||||
)
|
||||
|
||||
func StreakCard(s database.UserStreak) g.Node {
|
||||
return h.Div(
|
||||
g.Attr("class", "w-full"),
|
||||
h.Div(
|
||||
g.Attr("class", "relative w-full px-4 py-6 bg-white shadow-lg dark:bg-gray-700 rounded"),
|
||||
h.P(
|
||||
g.Attr("class", "text-sm font-semibold text-gray-700 border-b border-gray-200 w-max dark:text-white dark:border-gray-500"),
|
||||
g.If(s.Window == "WEEK", g.Text("Weekly Read Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Daily Read Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end my-6 space-x-2"),
|
||||
h.P(
|
||||
g.Attr("class", "text-5xl font-bold text-black dark:text-white"),
|
||||
g.Textf("%d", s.CurrentStreak),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "dark:text-white"),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm border-b border-gray-200"),
|
||||
h.Div(
|
||||
h.P(
|
||||
g.If(s.Window == "WEEK", g.Text("Current Weekly Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Current Daily Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end text-sm text-gray-400"),
|
||||
g.Textf("%s ➞ %s", s.CurrentStreakStartDate, s.CurrentStreakEndDate),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end font-bold"),
|
||||
g.Textf("%d", s.CurrentStreak),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-center justify-between pb-2 mb-2 text-sm"),
|
||||
h.Div(
|
||||
h.P(
|
||||
g.If(s.Window == "WEEK", g.Text("Best Weekly Streak")),
|
||||
g.If(s.Window != "WEEK", g.Text("Best Daily Streak")),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end text-sm text-gray-400"),
|
||||
g.Textf("%s ➞ %s", s.MaxStreakStartDate, s.MaxStreakEndDate),
|
||||
),
|
||||
),
|
||||
h.Div(
|
||||
g.Attr("class", "flex items-end font-bold"),
|
||||
g.Textf("%d", s.MaxStreak),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
99
web/components/ui/button.go
Normal file
99
web/components/ui/button.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
)
|
||||
|
||||
type ButtonVariant string
|
||||
|
||||
const (
|
||||
ButtonVariantPrimary ButtonVariant = "primary"
|
||||
ButtonVariantSecondary ButtonVariant = "secondary"
|
||||
ButtonVariantGhost ButtonVariant = "ghost"
|
||||
)
|
||||
|
||||
type buttonAs int
|
||||
|
||||
const (
|
||||
buttonAsLink buttonAs = iota
|
||||
buttonAsForm
|
||||
buttonAsSpan
|
||||
)
|
||||
|
||||
type ButtonConfig struct {
|
||||
Variant ButtonVariant
|
||||
Disabled bool
|
||||
|
||||
as buttonAs
|
||||
value string
|
||||
}
|
||||
|
||||
// LinkButton creates a button that links to a url. The default variant is ButtonVariantPrimary.
|
||||
func LinkButton(content g.Node, url string, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsLink, url)
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
// FormButton creates a button that is a form. The default variant is ButtonVariantPrimary.
|
||||
func FormButton(content g.Node, formName string, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsForm, formName)
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
// SpanButton creates a button that has no target (i.e. span). The default variant is ButtonVariantPrimary.
|
||||
func SpanButton(content g.Node, cfg ...ButtonConfig) g.Node {
|
||||
config := buildButtonConfig(cfg, buttonAsSpan, "")
|
||||
return button(content, config)
|
||||
}
|
||||
|
||||
func button(content g.Node, config ButtonConfig) g.Node {
|
||||
classes := config.getClasses()
|
||||
if config.as == buttonAsSpan || config.Disabled {
|
||||
return h.Span(content, h.Class(classes))
|
||||
} else if config.as == buttonAsLink {
|
||||
return h.A(h.Class(classes), h.Href(config.value), content)
|
||||
}
|
||||
|
||||
return h.Button(
|
||||
content,
|
||||
h.Type("submit"),
|
||||
h.Class(classes),
|
||||
g.If(config.value != "", h.FormAttr(config.value)),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *ButtonConfig) getClasses() string {
|
||||
baseClass := "transition duration-100 ease-in font-medium text-center inline-block"
|
||||
|
||||
var variantClass string
|
||||
switch c.Variant {
|
||||
case ButtonVariantPrimary:
|
||||
variantClass = "h-full w-full px-2 py-1 text-white bg-gray-500 dark:text-gray-800 hover:bg-gray-800 dark:hover:bg-gray-100"
|
||||
case ButtonVariantSecondary:
|
||||
variantClass = "h-full w-full px-2 py-1 text-white bg-black shadow-md hover:text-black hover:bg-white"
|
||||
case ButtonVariantGhost:
|
||||
variantClass = "text-gray-500 hover:text-gray-800 dark:hover:text-gray-100"
|
||||
}
|
||||
|
||||
classes := baseClass + " " + variantClass
|
||||
|
||||
if c.Disabled {
|
||||
classes += " opacity-40 pointer-events-none"
|
||||
}
|
||||
|
||||
return classes
|
||||
}
|
||||
|
||||
func buildButtonConfig(cfg []ButtonConfig, as buttonAs, val string) ButtonConfig {
|
||||
c, found := sliceutils.First(cfg)
|
||||
if !found {
|
||||
c = ButtonConfig{Variant: ButtonVariantPrimary}
|
||||
}
|
||||
c.Variant = utils.FirstNonZero(c.Variant, ButtonVariantPrimary)
|
||||
c.as = as
|
||||
c.value = val
|
||||
return c
|
||||
}
|
||||
24
web/components/ui/kv.go
Normal file
24
web/components/ui/kv.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
// KeyValue is a basic vertical key/value pair component
|
||||
func KeyValue(key, val g.Node) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex flex-col"),
|
||||
h.Div(h.Class("text-gray-500"), key),
|
||||
h.Div(h.Class("font-medium text-black dark:text-white"), val),
|
||||
)
|
||||
}
|
||||
|
||||
// HKeyValue is a basic horizontal key/value pair component
|
||||
func HKeyValue(key, val g.Node) g.Node {
|
||||
return h.Div(
|
||||
h.Class("flex gap-2"),
|
||||
h.Div(h.Class("text-gray-500"), key),
|
||||
h.Div(h.Class("font-medium text-black dark:text-white"), val),
|
||||
)
|
||||
}
|
||||
99
web/components/ui/popover.go
Normal file
99
web/components/ui/popover.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
"reichard.io/antholume/pkg/ptr"
|
||||
"reichard.io/antholume/pkg/sliceutils"
|
||||
"reichard.io/antholume/pkg/utils"
|
||||
)
|
||||
|
||||
type PopoverPosition string
|
||||
|
||||
const (
|
||||
// ---- Cornered ----
|
||||
|
||||
// PopoverTopLeft PopoverPosition = "left-0 top-0 origin-bottom-right -translate-x-full -translate-y-full"
|
||||
// PopoverTopRight PopoverPosition = "right-0 top-0 origin-bottom-left translate-x-full -translate-y-full"
|
||||
// PopoverBottomLeft PopoverPosition = "left-0 bottom-0 origin-top-right -translate-x-full translate-y-full"
|
||||
// PopoverBottomRight PopoverPosition = "right-0 bottom-0 origin-top-left translate-x-full translate-y-full"
|
||||
|
||||
// ---- Flush ----
|
||||
|
||||
PopoverTopLeft PopoverPosition = "right-0 -top-1 origin-bottom-right -translate-y-full"
|
||||
PopoverTopRight PopoverPosition = "left-0 -top-1 origin-bottom-left -translate-y-full"
|
||||
PopoverBottomLeft PopoverPosition = "right-0 -bottom-1 origin-top-right translate-y-full"
|
||||
PopoverBottomRight PopoverPosition = "left-0 -bottom-1 origin-top-left translate-y-full"
|
||||
|
||||
// ---- Centered ----
|
||||
|
||||
PopoverTopCenter PopoverPosition = "left-1/2 top-0 origin-bottom -translate-x-1/2 -translate-y-full"
|
||||
PopoverBottomCenter PopoverPosition = "left-1/2 bottom-0 origin-top -translate-x-1/2 translate-y-full"
|
||||
PopoverLeftCenter PopoverPosition = "left-0 top-1/2 origin-right -translate-x-full -translate-y-1/2"
|
||||
PopoverRightCenter PopoverPosition = "right-0 top-1/2 origin-left translate-x-full -translate-y-1/2"
|
||||
PopoverCenter PopoverPosition = "left-1/2 top-1/2 origin-center -translate-x-1/2 -translate-y-1/2"
|
||||
)
|
||||
|
||||
type PopoverConfig struct {
|
||||
Position PopoverPosition
|
||||
Classes string
|
||||
Dim *bool
|
||||
}
|
||||
|
||||
// AnchoredPopover creates a popover with content anchored to the anchor node.
|
||||
// The default position is PopoverBottomRight.
|
||||
func AnchoredPopover(anchor, content g.Node, cfg ...PopoverConfig) g.Node {
|
||||
// Get Popover Config
|
||||
c, _ := sliceutils.First(cfg)
|
||||
c.Position = utils.FirstNonZero(c.Position, PopoverBottomRight)
|
||||
if c.Dim == nil {
|
||||
c.Dim = ptr.Of(false)
|
||||
}
|
||||
|
||||
popoverID := uuid.NewString()
|
||||
return h.Div(
|
||||
h.Class("relative"),
|
||||
h.Label(
|
||||
h.Class("cursor-pointer"),
|
||||
h.For(popoverID),
|
||||
anchor,
|
||||
),
|
||||
h.Input(
|
||||
h.ID(popoverID),
|
||||
h.Class("hidden css-button"),
|
||||
h.Type("checkbox"),
|
||||
),
|
||||
Popover(content, c),
|
||||
)
|
||||
}
|
||||
|
||||
func Popover(content g.Node, cfg ...PopoverConfig) g.Node {
|
||||
// Get Popover Config
|
||||
c, _ := sliceutils.First(cfg)
|
||||
c.Position = utils.FirstNonZero(c.Position, PopoverCenter)
|
||||
if c.Dim == nil {
|
||||
c.Dim = ptr.Of(true)
|
||||
}
|
||||
|
||||
wrappedContent := h.Div(h.Class(c.getClasses()), content)
|
||||
if !ptr.Deref(c.Dim) {
|
||||
return wrappedContent
|
||||
}
|
||||
|
||||
return h.Div(
|
||||
h.Div(h.Class("fixed top-0 left-0 bg-black z-40 opacity-50 w-screen h-screen")),
|
||||
wrappedContent,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *PopoverConfig) getClasses() string {
|
||||
return strings.Join([]string{
|
||||
"absolute z-50 p-2 transition-all duration-200 rounded shadow-lg",
|
||||
"bg-gray-200 dark:bg-gray-600 shadow-gray-500 dark:shadow-gray-900",
|
||||
c.Classes,
|
||||
string(c.Position),
|
||||
}, " ")
|
||||
}
|
||||
64
web/components/ui/table.go
Normal file
64
web/components/ui/table.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
g "maragu.dev/gomponents"
|
||||
h "maragu.dev/gomponents/html"
|
||||
)
|
||||
|
||||
type TableRow map[string]TableCell
|
||||
|
||||
type TableCell struct {
|
||||
String string
|
||||
Value g.Node
|
||||
}
|
||||
|
||||
type TableConfig struct {
|
||||
Columns []string
|
||||
Rows []TableRow
|
||||
}
|
||||
|
||||
func Table(cfg TableConfig) g.Node {
|
||||
return h.Table(
|
||||
h.Class("min-w-full leading-normal bg-white dark:bg-gray-700 text-sm"),
|
||||
h.THead(
|
||||
h.Class("text-gray-800 dark:text-gray-400"),
|
||||
h.Tr(
|
||||
g.Map(cfg.Columns, func(col string) g.Node {
|
||||
return h.Th(
|
||||
h.Class("p-3 font-normal text-left uppercase border-b border-gray-200 dark:border-gray-800"),
|
||||
g.Text(col),
|
||||
)
|
||||
})...,
|
||||
),
|
||||
),
|
||||
h.TBody(
|
||||
h.Class("text-black dark:text-white"),
|
||||
g.If(len(cfg.Rows) == 0,
|
||||
h.Tr(
|
||||
h.Td(
|
||||
h.Class("text-center p-3"),
|
||||
g.Attr("colspan", fmt.Sprintf("%d", len(cfg.Columns))),
|
||||
g.Text("No Results"),
|
||||
),
|
||||
),
|
||||
),
|
||||
g.Map(cfg.Rows, func(row TableRow) g.Node {
|
||||
return h.Tr(
|
||||
g.Map(cfg.Columns, func(col string) g.Node {
|
||||
cell, ok := row[col]
|
||||
content := cell.Value
|
||||
if !ok || content == nil {
|
||||
content = g.Text(cell.String)
|
||||
}
|
||||
return h.Td(
|
||||
h.Class("p-3 border-b border-gray-200"),
|
||||
content,
|
||||
)
|
||||
})...,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user