Files
nfel 32e7b6b749
CI/CD Dev / dev (push) Failing after 44s
feat: add persistent dashboard dark mode
2026-08-15 00:53:58 +03:30

319 lines
10 KiB
Go

package web
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"syscall"
"time"
_ "time/tzdata"
"gl/application/explorer"
"gl/domain/ledger"
"github.com/a-h/templ"
)
type Explorer interface {
Dashboard(context.Context) (explorer.Dashboard, error)
Assets(context.Context) (explorer.Assets, error)
Transactions(context.Context, explorer.TransactionFilter) (explorer.TransactionListing, error)
Transaction(context.Context, string) ([]ledger.Journal, error)
Account(context.Context, ledger.AccountReference) (explorer.Account, error)
Holder(context.Context, explorer.HolderReference) (explorer.HolderAccount, error)
}
type Handler struct {
explorer Explorer
routes *http.ServeMux
}
type TransactionPageData struct {
Query string
Journals []ledger.Journal
Listing *explorer.TransactionListing
Error string
}
type AccountInput struct {
Class string
OwnerType string
OwnerID string
AssetID string
}
type AccountPageData struct {
Input AccountInput
Account *explorer.Account
Error string
}
type HolderPageData struct {
Account *explorer.HolderAccount
Error string
}
func NewHandler(service Explorer) *Handler {
handler := &Handler{explorer: service, routes: http.NewServeMux()}
handler.routes.HandleFunc("GET /favicon.ico", handler.favicon)
handler.routes.HandleFunc("GET /theme.js", handler.theme)
handler.routes.HandleFunc("GET /{$}", handler.dashboard)
handler.routes.HandleFunc("GET /assets", handler.assets)
handler.routes.HandleFunc("GET /transactions", handler.transaction)
handler.routes.HandleFunc("GET /transactions/{reference}", handler.transaction)
handler.routes.HandleFunc("GET /accounts", handler.account)
handler.routes.HandleFunc("GET /holders", handler.holder)
return handler
}
func (h *Handler) assets(response http.ResponseWriter, request *http.Request) {
locale := localeForRequest(response, request)
data, err := h.explorer.Assets(request.Context())
if err != nil {
h.renderFailure(response, request, locale, tr(locale, "assets_unavailable"), err)
return
}
h.render(response, request, http.StatusOK, locale, tr(locale, "title_assets"), "assets", AssetsContent(data, locale))
}
func (h *Handler) ServeHTTP(response http.ResponseWriter, request *http.Request) {
response.Header().Set("X-Content-Type-Options", "nosniff")
response.Header().Set("Referrer-Policy", "same-origin")
response.Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; base-uri 'self'; frame-ancestors 'none'")
h.routes.ServeHTTP(response, request)
}
func (h *Handler) dashboard(response http.ResponseWriter, request *http.Request) {
locale := localeForRequest(response, request)
data, err := h.explorer.Dashboard(request.Context())
if err != nil {
h.renderFailure(response, request, locale, tr(locale, "dashboard_unavailable"), err)
return
}
h.render(response, request, http.StatusOK, locale, tr(locale, "title_overview"), "dashboard", DashboardContent(data, locale))
}
func (h *Handler) transaction(response http.ResponseWriter, request *http.Request) {
locale := localeForRequest(response, request)
query := strings.TrimSpace(request.PathValue("reference"))
if query == "" {
query = strings.TrimSpace(request.URL.Query().Get("q"))
}
data := TransactionPageData{Query: query}
showLatest := query == ""
if query != "" {
journals, err := h.explorer.Transaction(request.Context(), query)
switch {
case err == nil:
data.Journals = journals
case requestEnded(request, err):
return
case explorer.IsNotFound(err):
data.Error = tr(locale, "transaction_missing")
showLatest = true
default:
data.Error = tr(locale, "transaction_lookup_error")
}
}
if showLatest {
page, parseErr := strconv.Atoi(strings.TrimSpace(request.URL.Query().Get("page")))
if parseErr != nil || page < 1 {
page = 1
}
listing, err := h.explorer.Transactions(request.Context(), explorer.TransactionFilter{
Page: page,
Wallet: request.URL.Query().Get("wallet"),
EffectKind: request.URL.Query().Get("effect"),
})
if requestEnded(request, err) {
return
} else if err != nil {
h.renderFailure(response, request, locale, tr(locale, "transactions_unavailable"), err)
return
}
data.Listing = &listing
}
h.render(response, request, http.StatusOK, locale, tr(locale, "title_transactions"), "transactions", TransactionContent(data, locale))
}
func (h *Handler) account(response http.ResponseWriter, request *http.Request) {
locale := localeForRequest(response, request)
data := AccountPageData{Input: AccountInput{
Class: strings.TrimSpace(request.URL.Query().Get("class")),
OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")),
OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")),
AssetID: strings.TrimSpace(request.URL.Query().Get("asset_id")),
}}
if request.URL.Query().Has("class") {
assetID, err := strconv.ParseInt(data.Input.AssetID, 10, 64)
if err != nil || assetID <= 0 {
data.Error = tr(locale, "asset_positive")
} else {
result, lookupErr := h.explorer.Account(request.Context(), ledger.AccountReference{
Class: ledger.AccountClass(data.Input.Class),
OwnerType: data.Input.OwnerType,
OwnerID: data.Input.OwnerID,
AssetID: assetID,
})
if requestEnded(request, lookupErr) {
return
} else if lookupErr != nil {
data.Error = tr(locale, "account_lookup_error")
} else {
data.Account = &result
}
}
}
h.render(response, request, http.StatusOK, locale, tr(locale, "title_accounts"), "accounts", AccountContent(data, locale))
}
func (h *Handler) holder(response http.ResponseWriter, request *http.Request) {
locale := localeForRequest(response, request)
data := HolderPageData{}
assetID, err := strconv.ParseInt(strings.TrimSpace(request.URL.Query().Get("asset_id")), 10, 64)
reference := explorer.HolderReference{
OwnerType: strings.TrimSpace(request.URL.Query().Get("owner_type")),
OwnerID: strings.TrimSpace(request.URL.Query().Get("owner_id")),
AssetID: assetID,
}
if err != nil || reference.OwnerType == "" || reference.OwnerID == "" || reference.AssetID <= 0 {
data.Error = tr(locale, "holder_reference_invalid")
} else {
result, lookupErr := h.explorer.Holder(request.Context(), reference)
if requestEnded(request, lookupErr) {
return
} else if lookupErr != nil {
data.Error = tr(locale, "account_lookup_error")
} else {
data.Account = &result
}
}
h.render(response, request, http.StatusOK, locale, tr(locale, "title_holder"), "assets", HolderContent(data, locale))
}
func (h *Handler) render(response http.ResponseWriter, request *http.Request, status int, locale Locale, title, active string, content templ.Component) {
component := Page(title, active, locale, localeURL(request, LocaleEnglish), localeURL(request, LocalePersian), content)
if request.Header.Get("HX-Request") == "true" && request.Header.Get("HX-History-Restore-Request") != "true" {
component = content
}
response.Header().Set("Content-Type", "text/html; charset=utf-8")
response.WriteHeader(status)
if err := component.Render(request.Context(), response); err != nil {
if requestEnded(request, err) {
return
}
slog.Error("render explorer", "error", err)
}
}
func (h *Handler) renderFailure(response http.ResponseWriter, request *http.Request, locale Locale, title string, err error) {
if requestEnded(request, err) {
return
}
slog.Error("explorer request failed", "error", err)
h.render(response, request, http.StatusInternalServerError, locale, title, "", FailureContent(title, tr(locale, "read_model_unavailable"), locale))
}
func requestEnded(request *http.Request, err error) bool {
return request.Context().Err() != nil ||
errors.Is(err, context.Canceled) ||
errors.Is(err, syscall.EPIPE) ||
errors.Is(err, syscall.ECONNRESET)
}
func accountClasses() []ledger.AccountClass {
return []ledger.AccountClass{
ledger.AccountClassUserAvailable,
ledger.AccountClassUserFrozen,
ledger.AccountClassExternalBlockchain,
ledger.AccountClassTreasury,
ledger.AccountClassMarketClearing,
ledger.AccountClassIPGClearing,
ledger.AccountClassCommissionRevenue,
}
}
func formatTime(value time.Time) string {
if value.IsZero() || value.Unix() == 0 {
return "—"
}
return value.In(defaultTimezone).Format("02 Jan 2006 · 15:04:05") + " Asia/Tehran"
}
func short(value string, size int) string {
if len(value) <= size {
return value
}
left := size / 2
return value[:left] + "…" + value[len(value)-(size-left):]
}
func count(value int64) string {
text := strconv.FormatInt(value, 10)
for index := len(text) - 3; index > 0; index -= 3 {
text = text[:index] + "," + text[index:]
}
return text
}
func transactionName(journal ledger.Journal, locale Locale) string {
if journal.Blockchain.TransactionHash != "" {
return short(journal.Blockchain.TransactionHash, 22)
}
return tr(locale, "internal") + " · " + short(journal.ID, 14)
}
func transactionReference(journal ledger.Journal) string {
if journal.Blockchain.TransactionHash != "" {
return journal.Blockchain.TransactionHash
}
return journal.ID
}
func transactionURL(reference string) string {
return "/transactions/" + url.PathEscape(reference)
}
func transactionPageURL(filter explorer.TransactionFilter, page int) string {
values := url.Values{"page": []string{strconv.Itoa(page)}}
if filter.Wallet != "" {
values.Set("wallet", filter.Wallet)
}
if filter.EffectKind != "" {
values.Set("effect", filter.EffectKind)
}
return "/transactions?" + values.Encode()
}
func accountURL(account ledger.AccountReference) string {
values := url.Values{
"class": []string{string(account.Class)},
"owner_type": []string{account.OwnerType},
"owner_id": []string{account.OwnerID},
"asset_id": []string{strconv.FormatInt(account.AssetID, 10)},
}
return "/accounts?" + values.Encode()
}
func holderURL(holder explorer.Holder) string {
values := url.Values{
"owner_type": []string{holder.OwnerType},
"owner_id": []string{holder.OwnerID},
"asset_id": []string{strconv.FormatInt(holder.AssetID, 10)},
}
return "/holders?" + values.Encode()
}
func accountName(account ledger.AccountReference, locale Locale) string {
owner := account.OwnerID
if owner == "" {
owner = tr(locale, "system")
}
return fmt.Sprintf("%s / %s", account.Class, owner)
}