This commit is contained in:
2025-08-30 20:52:27 -04:00
parent e7ebccd4a9
commit f53959b38f
31 changed files with 789 additions and 479 deletions

View File

@@ -9,6 +9,7 @@ import (
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Activity)(nil)
@@ -17,14 +18,15 @@ type Activity struct {
Data []models.Activity
}
func (Activity) Route() PageRoute { return ActivityPage }
func (p Activity) Render() g.Node {
return h.Div(
h.Class("overflow-x-auto"),
func (p *Activity) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.ActivityPage),
h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
h.Class("overflow-x-auto"),
h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
),
),
)
}

View File

@@ -13,6 +13,7 @@ import (
"reichard.io/antholume/web/components/document"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Document)(nil)
@@ -22,9 +23,14 @@ type Document struct {
Search *models.DocumentMetadata
}
func (Document) Route() PageRoute { return DocumentPage }
func (p *Document) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.DocumentPage),
p.content(),
)
}
func (p Document) Render() g.Node {
func (p *Document) content() g.Node {
return h.Div(
h.Class("h-full w-full overflow-scroll bg-white shadow-lg dark:bg-gray-700 rounded dark:text-white p-4"),
document.Actions(p.Data),

View File

@@ -9,6 +9,7 @@ import (
"reichard.io/antholume/web/components/document"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Documents)(nil)
@@ -20,15 +21,13 @@ type Documents struct {
Limit int
}
func (Documents) Route() PageRoute { return DocumentsPage }
func (p Documents) Render() g.Node {
return g.Group([]g.Node{
func (p Documents) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(ctx.WithRoute(models.DocumentsPage),
searchBar(),
documentGrid(p.Data),
pagination(p.Previous, p.Next, p.Limit),
uploadFAB(),
})
)
}
func searchBar() g.Node {

View File

@@ -5,6 +5,8 @@ import (
h "maragu.dev/gomponents/html"
"reichard.io/antholume/database"
"reichard.io/antholume/web/components/stats"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Home)(nil)
@@ -16,9 +18,11 @@ type Home struct {
RecordInfo *database.GetDatabaseInfoRow
}
func (Home) Route() PageRoute { return HomePage }
func (p *Home) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(ctx.WithRoute(models.HomePage), p.content())
}
func (p Home) Render() g.Node {
func (p *Home) content() g.Node {
return h.Div(
g.Attr("class", "flex flex-col gap-4"),
h.Div(

View File

@@ -0,0 +1,63 @@
package layout
import (
"errors"
"fmt"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
)
func Layout(ctx models.PageContext, children ...g.Node) (g.Node, error) {
if ctx.UserInfo == nil {
return nil, errors.New("no user info")
} else if ctx.ServerInfo == nil {
return nil, errors.New("no server info")
} else if !ctx.Route.Valid() {
return nil, fmt.Errorf("invalid route: %s", ctx.Route)
}
return h.Doctype(
h.HTML(
g.Attr("lang", "en"),
Head(ctx.Route.Title()),
h.Body(
g.Attr("class", "bg-gray-100 dark:bg-gray-800 text-black dark:text-white"),
Navigation(ctx),
Base(children),
ui.Notifications(ctx.Notifications),
),
),
), nil
}
func Head(routeTitle string) g.Node {
return h.Head(
g.El("title", g.Text("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"),
g.Group(body),
),
)
}

View File

@@ -0,0 +1,167 @@
package layout
import (
"strings"
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/models"
)
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(ctx models.PageContext) g.Node {
return h.Div(
g.Attr("class", "flex items-center justify-between w-full h-16"),
Sidebar(ctx),
h.H1(g.Attr("class", "text-xl font-bold px-6 lg:ml-44"), g.Text(ctx.Route.Title())),
Dropdown(ctx.UserInfo.Username),
)
}
func Sidebar(ctx models.PageContext) g.Node {
links := []g.Node{
navLink(ctx.Route, models.HomePage, "/", "home"),
navLink(ctx.Route, models.DocumentsPage, "/documents", "documents"),
navLink(ctx.Route, models.ProgressPage, "/progress", "activity"),
navLink(ctx.Route, models.ActivityPage, "/activity", "activity"),
}
if ctx.ServerInfo.SearchEnabled {
links = append(links, navLink(ctx.Route, models.SearchPage, "/search", "search"))
}
if ctx.UserInfo.IsAdmin {
links = append(links, adminLinks(ctx.Route))
}
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(ctx.ServerInfo.Version)),
),
),
)
}
func navLink(currentRoute, linkRoute models.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 models.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, models.AdminGeneralPage, "/admin"),
subNavLink(currentRoute, models.AdminImportPage, "/admin/import"),
subNavLink(currentRoute, models.AdminUsersPage, "/admin/users"),
subNavLink(currentRoute, models.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 models.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/pages/layout/route.go Normal file
View File

@@ -0,0 +1,35 @@
package layout
type Route string
const (
HomePage Route = "home"
DocumentPage Route = "document"
DocumentsPage Route = "documents"
ProgressPage Route = "progress"
ActivityPage Route = "activity"
SearchPage Route = "search"
SettingsPage Route = "settings"
AdminGeneralPage Route = "admin-general"
AdminImportPage Route = "admin-import"
AdminUsersPage Route = "admin-users"
AdminLogsPage Route = "admin-logs"
)
var pageTitleMap = map[Route]string{
HomePage: "Home",
DocumentPage: "Document",
DocumentsPage: "Documents",
ProgressPage: "Progress",
ActivityPage: "Activity",
SearchPage: "Search",
SettingsPage: "Settings",
AdminGeneralPage: "Admin - General",
AdminImportPage: "Admin - Import",
AdminUsersPage: "Admin - Users",
AdminLogsPage: "Admin - Logs",
}
func (p Route) Title() string {
return pageTitleMap[p]
}

View File

@@ -2,41 +2,9 @@ package pages
import (
g "maragu.dev/gomponents"
"reichard.io/antholume/web/models"
)
type PageRoute string
const (
HomePage PageRoute = "home"
DocumentPage PageRoute = "document"
DocumentsPage PageRoute = "documents"
ProgressPage PageRoute = "progress"
ActivityPage PageRoute = "activity"
SearchPage PageRoute = "search"
AdminGeneralPage PageRoute = "admin-general"
AdminImportPage PageRoute = "admin-import"
AdminUsersPage PageRoute = "admin-users"
AdminLogsPage PageRoute = "admin-logs"
)
var pageTitleMap = map[PageRoute]string{
HomePage: "Home",
DocumentPage: "Document",
DocumentsPage: "Documents",
ProgressPage: "Progress",
ActivityPage: "Activity",
SearchPage: "Search",
AdminGeneralPage: "Admin - General",
AdminImportPage: "Admin - Import",
AdminUsersPage: "Admin - Users",
AdminLogsPage: "Admin - Logs",
}
func (p PageRoute) Title() string {
return pageTitleMap[p]
}
type Page interface {
Route() PageRoute
Render() g.Node
Generate(ctx models.PageContext) (g.Node, error)
}

View File

@@ -8,6 +8,7 @@ import (
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Progress)(nil)
@@ -16,14 +17,15 @@ type Progress struct {
Data []models.Progress
}
func (Progress) Route() PageRoute { return ProgressPage }
func (p Progress) Render() g.Node {
return h.Div(
h.Class("overflow-x-auto"),
func (p *Progress) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.ProgressPage),
h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
h.Class("overflow-x-auto"),
h.Div(
h.Class("inline-block min-w-full overflow-hidden rounded shadow"),
ui.Table(p.buildTableConfig()),
),
),
)
}

View File

@@ -12,6 +12,7 @@ import (
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Search)(nil)
@@ -23,9 +24,14 @@ type Search struct {
Error string
}
func (Search) Route() PageRoute { return SearchPage }
func (p Search) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.SearchPage),
p.content(),
)
}
func (p Search) Render() g.Node {
func (p *Search) content() g.Node {
return h.Div(
h.Class("flex flex-col gap-4"),
h.Div(
@@ -96,7 +102,7 @@ func (p Search) Render() g.Node {
)
}
func (p Search) tableRows() []ui.TableRow {
func (p *Search) tableRows() []ui.TableRow {
return sliceutils.Map(p.Results, func(r models.SearchResult) ui.TableRow {
return ui.TableRow{
"": ui.TableCell{

184
web/pages/settings.go Normal file
View File

@@ -0,0 +1,184 @@
package pages
import (
g "maragu.dev/gomponents"
h "maragu.dev/gomponents/html"
"reichard.io/antholume/pkg/sliceutils"
"reichard.io/antholume/web/assets"
"reichard.io/antholume/web/components/ui"
"reichard.io/antholume/web/models"
"reichard.io/antholume/web/pages/layout"
)
var _ Page = (*Settings)(nil)
type Settings struct {
Timezone string
Devices []models.Device
}
func (p *Settings) Generate(ctx models.PageContext) (g.Node, error) {
return layout.Layout(
ctx.WithRoute(models.SettingsPage),
h.Div(
h.Class("flex flex-col md:flex-row gap-4"),
h.Div(
h.Div(
h.Class("flex flex-col p-4 items-center rounded shadow-lg md:w-60 lg:w-80 bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
assets.Icon("user", 60),
h.P(h.Class("text-lg"), g.Text(ctx.UserInfo.Username)),
),
),
h.Div(
h.Class("flex flex-col gap-4 grow"),
p.passwordForm(),
p.timezoneForm(),
p.devicesTable(),
),
),
)
}
func (p Settings) passwordForm() g.Node {
return h.Div(
h.Class("flex flex-col gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Change Password")),
h.Form(
h.Class("flex gap-4 flex-col lg:flex-row"),
h.Action("./settings"),
h.Method("POST"),
// Current Password
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("password", 15),
),
h.Input(
h.Type("password"),
h.ID("password"),
h.Name("password"),
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.Placeholder("Password"),
),
),
// New Password
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("password", 15),
),
h.Input(
h.Type("password"),
h.ID("new_password"),
h.Name("new_password"),
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.Placeholder("New Password"),
),
),
// Submit Button
h.Div(
h.Class("lg:w-60"),
ui.FormButton(
g.Text("Submit"),
"",
ui.ButtonConfig{Variant: ui.ButtonVariantSecondary},
),
),
),
)
}
func (p Settings) timezoneForm() g.Node {
tzs := []string{
"Africa/Cairo",
"Africa/Johannesburg",
"Africa/Lagos",
"Africa/Nairobi",
"America/Adak",
"America/Anchorage",
"America/Buenos_Aires",
"America/Chicago",
"America/Denver",
"America/Los_Angeles",
"America/Mexico_City",
"America/New_York",
"America/Nuuk",
"America/Phoenix",
"America/Puerto_Rico",
"America/Sao_Paulo",
"America/St_Johns",
"America/Toronto",
"Asia/Dubai",
"Asia/Hong_Kong",
"Asia/Kolkata",
"Asia/Seoul",
"Asia/Shanghai",
"Asia/Singapore",
"Asia/Tokyo",
"Atlantic/Azores",
"Australia/Melbourne",
"Australia/Sydney",
"Europe/Berlin",
"Europe/London",
"Europe/Moscow",
"Europe/Paris",
"Pacific/Auckland",
"Pacific/Honolulu",
}
return h.Div(
h.Class("flex flex-col grow gap-2 p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Change Timezone")),
h.Form(
h.Class("flex gap-4 flex-col lg:flex-row"),
h.Action("./settings"),
h.Method("POST"),
h.Div(
h.Class("flex grow"),
h.Span(
h.Class("inline-flex items-center px-3 border-t bg-white border-l border-b border-gray-300 text-gray-500 shadow-sm text-sm"),
assets.Icon("clock", 15),
),
h.Select(
h.Class("flex-1 appearance-none rounded-none border border-gray-300 w-full py-2 px-4 bg-white text-gray-700 placeholder-gray-400 shadow-sm text-base focus:outline-none focus:ring-2 focus:ring-purple-600 focus:border-transparent"),
h.ID("timezone"),
h.Name("timezone"),
g.Group(g.Map(tzs, func(tz string) g.Node {
return h.Option(
h.Value(tz),
g.If(tz == p.Timezone, h.Selected()),
g.Text(tz),
)
})),
),
),
h.Div(
h.Class("lg:w-60"),
ui.FormButton(
g.Text("Submit"),
"",
ui.ButtonConfig{Variant: ui.ButtonVariantSecondary},
),
),
),
)
}
func (p Settings) devicesTable() g.Node {
return h.Div(
h.Class("flex flex-col grow p-4 rounded shadow-lg bg-white dark:bg-gray-700 text-gray-500 dark:text-white"),
h.P(h.Class("text-lg font-semibold"), g.Text("Devices")),
ui.Table(ui.TableConfig{
Columns: []string{"Name", "Last Sync", "Created"},
Rows: sliceutils.Map(p.Devices, func(d models.Device) ui.TableRow {
return ui.TableRow{
"Name": ui.TableCell{String: d.DeviceName},
"Last Sync": ui.TableCell{String: d.LastSynced},
"Created": ui.TableCell{String: d.CreatedAt},
}
}),
}),
)
}