Merge branch 'task-react' into server-self-host
* task-react: (47 commits) chore: remove views and further changes chore: fixes in new and show feat: add success screen chore: split user from info endpoint chore: extract csrf out of info endpoint fix: use POST to logout chore: move login redirect to react fix api response codes if user is nil feat: add toasts and new app error handling feat: add api models fix: remove unnecessary user info display in new app page fix: navbar link styles fix: root styling fix: about page image and spacing fix: caret in back link chore: rename components fix: app expires at display fix: last accessed display fix: plus icon fix: use container for content ...
5
.gitignore
vendored
|
|
@ -5,4 +5,7 @@
|
|||
node_modules
|
||||
nostr-wallet-connect
|
||||
nwc.db
|
||||
.breez
|
||||
.breez
|
||||
|
||||
frontend/dist
|
||||
frontend/node_modules
|
||||
|
|
|
|||
6
.vscode/settings.json
vendored
|
|
@ -1,3 +1,7 @@
|
|||
{
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"[go]": {
|
||||
"editor.defaultFormatter": "golang.go"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
|
@ -19,6 +19,8 @@ RUN go mod download
|
|||
COPY . .
|
||||
|
||||
RUN go build -o main .
|
||||
# TODO: build react app?
|
||||
|
||||
|
||||
# Start a new, final image to reduce size.
|
||||
FROM alpine as final
|
||||
|
|
@ -29,7 +31,8 @@ FROM alpine as final
|
|||
|
||||
# Copy the binaries and entrypoint from the builder image.
|
||||
COPY --from=builder /build/main /bin/
|
||||
COPY --from=builder /build/public /public/
|
||||
COPY --from=builder /build/views /views/
|
||||
# NOTE: should not be needed - assets should be embedded in the go app
|
||||
#COPY --from=builder /build/public /public/
|
||||
#COPY --from=builder /build/views /views/
|
||||
|
||||
ENTRYPOINT [ "/bin/main" ]
|
||||
|
|
|
|||
52
README.md
|
|
@ -3,15 +3,13 @@
|
|||
This application allows you to control your Lightning node or wallet over Nostr.
|
||||
Connect applications like [Damus](https://damus.io/) or [Amethyst](https://linktr.ee/amethyst.social) to your node.
|
||||
|
||||
|
||||
|
||||
**Specification**: [NIP-47](https://github.com/nostr-protocol/nips/blob/master/47.md)
|
||||
|
||||
## Supported Backends
|
||||
|
||||
* [Alby](https://getalby.com) (see: alby.go)
|
||||
* LND (see: lnd.go)
|
||||
* want more? please open an issue.
|
||||
- [Alby](https://getalby.com) (see: alby.go)
|
||||
- LND (see: lnd.go)
|
||||
- want more? please open an issue.
|
||||
|
||||
## Installation
|
||||
|
||||
|
|
@ -25,16 +23,32 @@ As data storage SQLite or PostgreSQL (recommended) can be used.
|
|||
# edit the config for your needs
|
||||
vim .env
|
||||
|
||||
To get a new random Nostr key use `openssl rand -hex 32` or similar.
|
||||
To get a new random Nostr key use `openssl rand -hex 32` or similar.
|
||||
|
||||
## Development
|
||||
|
||||
`go run .` or `gow -e=go,mod,html,css run .` using [gow](https://github.com/mitranim/gow)
|
||||
### Server (LND)
|
||||
|
||||
To build the CSS run:
|
||||
1. Create a Lightning Polar setup with two LND nodes and uncomment the Polar LND section in your `.env` file.
|
||||
|
||||
1. `npm install`
|
||||
2. `npm run css`
|
||||
2. Compile the frontend or run `touch frontend/dist/tmp` to ensure there are embeddable files available.
|
||||
|
||||
3. `go run .` or `gow -e=go,mod,html,css run .` using [gow](https://github.com/mitranim/gow)
|
||||
|
||||
### Server (Alby Wallet API)
|
||||
|
||||
Generate a new OAuth client for <http://localhost:8080> from the [Alby developer portal](https://getalby.com/developer) and set `ALBY_CLIENT_ID` and `ALBY_CLIENT_SECRET` in your .env file.
|
||||
|
||||
### React Frontend (LND)
|
||||
|
||||
Go to `/frontend`
|
||||
|
||||
1. `yarn install`
|
||||
2. `yarn dev`
|
||||
|
||||
### React Frontend (Alby Wallet API)
|
||||
|
||||
Follow standard LND instructions. After logging in, you will be redirected to the wrong port (8080), so manually re-open <http://localhost:5173>.
|
||||
|
||||
### Testing
|
||||
|
||||
|
|
@ -64,6 +78,7 @@ To build the CSS run:
|
|||
Clients can use a deeplink to allow the user to add a new connection. Depending on the client this URL has different query options:
|
||||
|
||||
#### NWC created secret
|
||||
|
||||
The default option is that the NWC app creates a secret and the user uses the nostr wallet connect URL string to enable the client application.
|
||||
|
||||
##### Query parameter options
|
||||
|
|
@ -75,22 +90,25 @@ Example:
|
|||
`/apps/new?name=myapp`
|
||||
|
||||
#### Client created secret
|
||||
|
||||
If the client creates the secret the client only needs to share the public key of that secret for authorization. The user authorized that pubkey and no sensitivate data needs to be shared.
|
||||
|
||||
##### Query parameter options for /new
|
||||
|
||||
- `name`: the name of the client app
|
||||
- `pubkey`: the public key of the client's secret for the user to authorize
|
||||
- `return_to`: (optional) if a `return_to` URL is provided the user will be redirected to that URL after authorization. The `lud16`, `relay` and `pubkey` query parameters will be added to the URL.
|
||||
- `expires_at` (optional) connection cannot be used after this date. Unix timestamp in seconds.
|
||||
- `max_amount` (optional) maximum amount in sats that can be sent per renewal period
|
||||
- `budget_renewal` (optional) reset the budget at the end of the given budget renewal. Can be `never` (default), `daily`, `weekly`, `monthly`, `yearly`
|
||||
- `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance`
|
||||
- `request_methods` (optional) url encoded, space separated list of request types that you need permission for: `pay_invoice` (default), `get_balance` (see NIP47). For example: `..&request_methods=pay_invoice%20get_balance`
|
||||
|
||||
Example:
|
||||
|
||||
`/apps/new?name=myapp&pubkey=47c5a21...&return_to=https://example.com`
|
||||
|
||||
#### Web-flow: client created secret
|
||||
|
||||
Web clients can open a new prompt popup to load the authorization page.
|
||||
Once the user has authorized the app connection a `nwc:success` message is sent to the opening page (using `postMessage`) to indicate that the connection is authorized. See the `initNWC()` function in the [alby-js-sdk](https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation)
|
||||
|
||||
|
|
@ -102,15 +120,14 @@ const nwc = new webln.NWC();
|
|||
// initNWC opens a prompt with /apps/new?c=myapp&pubkey=xxxx
|
||||
// the promise resolves once the user has authorized the connection (when the `nwc:success` message is received) and the popup is closed automatically
|
||||
// the promise rejects if the user cancels by closing the prompt popup
|
||||
await nwc.initNWC({name: 'myapp'});
|
||||
````
|
||||
await nwc.initNWC({ name: "myapp" });
|
||||
```
|
||||
|
||||
## Help
|
||||
|
||||
If you need help contact support@getalby.com or reach out on Nostr: npub1getal6ykt05fsz5nqu4uld09nfj3y3qxmv8crys4aeut53unfvlqr80nfm
|
||||
You can also visit the chat of our Community on [Telegram](https://t.me/getalby).
|
||||
|
||||
|
||||
## ⚡️Donations
|
||||
|
||||
Want to support the work on Alby?
|
||||
|
|
@ -118,7 +135,6 @@ Want to support the work on Alby?
|
|||
Support the Alby team ⚡️hello@getalby.com
|
||||
You can also contribute to our [bounty program](https://github.com/getAlby/lightning-browser-extension/wiki/Bounties): ⚡️bounties@getalby.com
|
||||
|
||||
|
||||
## NIP-47 Supported Methods
|
||||
|
||||
✅ NIP-47 info event
|
||||
|
|
@ -140,6 +156,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
|
|||
✅ `lookup_invoice`
|
||||
|
||||
✅ `list_transactions`
|
||||
|
||||
- ⚠️ from and until in request not supported
|
||||
- ⚠️ failed payments will not be returned
|
||||
|
||||
|
|
@ -150,6 +167,7 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
|
|||
### Alby OAuth API
|
||||
|
||||
✅ `get_info`
|
||||
|
||||
- ⚠️ block_hash not supported
|
||||
- ⚠️ block_height not supported
|
||||
- ⚠️ pubkey not supported
|
||||
|
|
@ -161,15 +179,19 @@ You can also contribute to our [bounty program](https://github.com/getAlby/light
|
|||
✅ `pay_invoice`
|
||||
|
||||
✅ `pay_keysend`
|
||||
|
||||
- ⚠️ preimage in request not supported
|
||||
|
||||
✅ `make_invoice`
|
||||
|
||||
- ⚠️ expiry in request not supported
|
||||
|
||||
✅ `lookup_invoice`
|
||||
|
||||
- ⚠️ fees_paid in response not supported
|
||||
|
||||
✅ `list_transactions`
|
||||
|
||||
- ⚠️ offset and unpaid in request not supported
|
||||
- ⚠️ fees_paid in response not supported
|
||||
- ⚠️ unsettled and failed transactions will not be returned
|
||||
|
|
|
|||
387
echo_handlers.go
|
|
@ -4,10 +4,7 @@ import (
|
|||
"crypto/subtle"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -16,6 +13,9 @@ import (
|
|||
"time"
|
||||
|
||||
echologrus "github.com/davrux/echo-logrus/v4"
|
||||
// "github.com/getAlby/lndhub.go/lib/responses"
|
||||
"github.com/getAlby/nostr-wallet-connect/frontend"
|
||||
"github.com/getAlby/nostr-wallet-connect/models/api"
|
||||
"github.com/gorilla/sessions"
|
||||
"github.com/labstack/echo-contrib/session"
|
||||
"github.com/labstack/echo/v4"
|
||||
|
|
@ -26,59 +26,16 @@ import (
|
|||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
//go:embed public/*
|
||||
//go:embed frontend/dist/*
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
//go:embed views/*
|
||||
var embeddedViews embed.FS
|
||||
|
||||
type TemplateRegistry struct {
|
||||
templates map[string]*template.Template
|
||||
}
|
||||
|
||||
// Implement e.Renderer interface
|
||||
func (t *TemplateRegistry) Render(w io.Writer, name string, data interface{}, c echo.Context) error {
|
||||
tmpl, ok := t.templates[name]
|
||||
if !ok {
|
||||
err := errors.New("Template not found -> " + name)
|
||||
return err
|
||||
}
|
||||
return tmpl.ExecuteTemplate(w, "layout.html", data)
|
||||
}
|
||||
|
||||
func (svc *Service) RegisterSharedRoutes(e *echo.Echo) {
|
||||
|
||||
templates := make(map[string]*template.Template)
|
||||
templates["apps/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/index.html", "views/layout.html"))
|
||||
templates["apps/new.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/new.html", "views/layout.html"))
|
||||
templates["apps/show.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/show.html", "views/layout.html"))
|
||||
templates["apps/create.html"] = template.Must(template.ParseFS(embeddedViews, "views/apps/create.html", "views/layout.html"))
|
||||
templates["alby/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/alby/index.html", "views/layout.html"))
|
||||
templates["about.html"] = template.Must(template.ParseFS(embeddedViews, "views/about.html", "views/layout.html"))
|
||||
templates["404.html"] = template.Must(template.ParseFS(embeddedViews, "views/404.html", "views/layout.html"))
|
||||
templates["lnd/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/lnd/index.html", "views/layout.html"))
|
||||
templates["breez/index.html"] = template.Must(template.ParseFS(embeddedViews, "views/backends/breez/index.html", "views/layout.html"))
|
||||
e.Renderer = &TemplateRegistry{
|
||||
templates: templates,
|
||||
}
|
||||
e.HideBanner = true
|
||||
e.Use(echologrus.Middleware())
|
||||
|
||||
if svc.cfg.BasicAuthUser != "" && svc.cfg.BasicAuthPassword != "" {
|
||||
e.Use(middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
|
||||
// Be careful to use constant time comparison to prevent timing attacks
|
||||
if subtle.ConstantTimeCompare([]byte(username), []byte(svc.cfg.BasicAuthUser)) == 1 &&
|
||||
subtle.ConstantTimeCompare([]byte(password), []byte(svc.cfg.BasicAuthPassword)) == 1 {
|
||||
return true, nil
|
||||
}
|
||||
return false, nil
|
||||
}))
|
||||
}
|
||||
|
||||
e.Use(middleware.Recover())
|
||||
e.Use(middleware.RequestID())
|
||||
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
|
||||
TokenLookup: "form:_csrf",
|
||||
TokenLookup: "header:X-CSRF-Token,form:_csrf",
|
||||
}))
|
||||
e.Use(session.Middleware(sessions.NewCookieStore([]byte(svc.cfg.CookieSecret))))
|
||||
e.Use(ddEcho.Middleware(ddEcho.WithServiceName("nostr-wallet-connect")))
|
||||
|
|
@ -86,37 +43,15 @@ func (svc *Service) RegisterSharedRoutes(e *echo.Echo) {
|
|||
assetSubdir, _ := fs.Sub(embeddedAssets, "public")
|
||||
assetHandler := http.FileServer(http.FS(assetSubdir))
|
||||
e.GET("/public/*", echo.WrapHandler(http.StripPrefix("/public/", assetHandler)))
|
||||
e.GET("/apps", svc.AppsListHandler)
|
||||
e.GET("/apps/new", svc.AppsNewHandler)
|
||||
e.GET("/apps/:pubkey", svc.AppsShowHandler)
|
||||
e.POST("/apps", svc.AppsCreateHandler)
|
||||
e.POST("/apps/delete/:pubkey", svc.AppsDeleteHandler)
|
||||
e.GET("/logout", svc.LogoutHandler)
|
||||
e.GET("/about", svc.AboutHandler)
|
||||
e.GET("/", svc.IndexHandler)
|
||||
}
|
||||
|
||||
func (svc *Service) IndexHandler(c echo.Context) error {
|
||||
sess, _ := session.Get(CookieName, c)
|
||||
returnTo := sess.Values["return_to"]
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user != nil && returnTo != nil {
|
||||
delete(sess.Values, "return_to")
|
||||
sess.Options.MaxAge = 0
|
||||
sess.Options.SameSite = http.SameSiteLaxMode
|
||||
if svc.cfg.CookieDomain != "" {
|
||||
sess.Options.Domain = svc.cfg.CookieDomain
|
||||
}
|
||||
sess.Save(c.Request(), c.Response())
|
||||
return c.Redirect(302, fmt.Sprintf("%s", returnTo))
|
||||
}
|
||||
if user != nil {
|
||||
return c.Redirect(302, "/apps")
|
||||
}
|
||||
return c.Render(http.StatusOK, fmt.Sprintf("%s/index.html", strings.ToLower(svc.cfg.LNBackendType)), map[string]interface{}{})
|
||||
e.GET("/api/csrf", svc.CSRFHandler)
|
||||
e.GET("/api/apps", svc.AppsListHandler)
|
||||
e.GET("/api/apps/:pubkey", svc.AppsShowHandler)
|
||||
e.POST("/api/apps", svc.AppsCreateHandler)
|
||||
e.DELETE("/api/apps/:pubkey", svc.AppsDeleteHandler)
|
||||
e.GET("/api/user/me", svc.UserMeHandler)
|
||||
e.GET("/api/info", svc.InfoHandler)
|
||||
e.POST("/api/logout", svc.LogoutHandler)
|
||||
frontend.RegisterHandlers(e)
|
||||
}
|
||||
|
||||
func (svc *Service) AboutHandler(c echo.Context) error {
|
||||
|
|
@ -132,217 +67,125 @@ func (svc *Service) AboutHandler(c echo.Context) error {
|
|||
func (svc *Service) AppsListHandler(c echo.Context) error {
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
|
||||
})
|
||||
}
|
||||
if user == nil {
|
||||
return c.Redirect(302, "/")
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
apps := user.Apps
|
||||
apps := []api.App{}
|
||||
|
||||
for _, app := range user.Apps {
|
||||
apiApp := api.App{
|
||||
// ID: app.ID,
|
||||
Name: app.Name,
|
||||
Description: app.Description,
|
||||
CreatedAt: app.CreatedAt,
|
||||
UpdatedAt: app.UpdatedAt,
|
||||
NostrPubkey: app.NostrPubkey,
|
||||
}
|
||||
|
||||
lastEvents := make(map[uint]NostrEvent)
|
||||
eventsCounts := make(map[uint]int64)
|
||||
for _, app := range apps {
|
||||
var lastEvent NostrEvent
|
||||
var eventsCount int64
|
||||
svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
|
||||
svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
|
||||
lastEvents[app.ID] = lastEvent
|
||||
eventsCounts[app.ID] = eventsCount
|
||||
result := svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
|
||||
if result.RowsAffected > 0 {
|
||||
apiApp.LastEventAt = &lastEvent.CreatedAt
|
||||
}
|
||||
apps = append(apps, apiApp)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "apps/index.html", map[string]interface{}{
|
||||
"Apps": apps,
|
||||
"User": user,
|
||||
"LastEvents": lastEvents,
|
||||
"EventsCounts": eventsCounts,
|
||||
})
|
||||
return c.JSON(http.StatusOK, apps)
|
||||
}
|
||||
|
||||
func (svc *Service) AppsShowHandler(c echo.Context) error {
|
||||
csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
|
||||
// csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
|
||||
})
|
||||
}
|
||||
if user == nil {
|
||||
return c.Redirect(302, "/")
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
app := App{}
|
||||
svc.db.Where("user_id = ? AND nostr_pubkey = ?", user.ID, c.Param("pubkey")).First(&app)
|
||||
|
||||
if app.NostrPubkey == "" {
|
||||
return c.Render(http.StatusNotFound, "404.html", map[string]interface{}{
|
||||
"User": user,
|
||||
return c.JSON(http.StatusNotFound, ErrorResponse{
|
||||
Error: true,
|
||||
Message: "App does not exist",
|
||||
})
|
||||
}
|
||||
|
||||
lastEvent := NostrEvent{}
|
||||
svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
|
||||
var eventsCount int64
|
||||
svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
|
||||
var lastEvent NostrEvent
|
||||
lastEventResult := svc.db.Where("app_id = ?", app.ID).Order("id desc").Limit(1).Find(&lastEvent)
|
||||
//var eventsCount int64
|
||||
//svc.db.Model(&NostrEvent{}).Where("app_id = ?", app.ID).Count(&eventsCount)
|
||||
|
||||
paySpecificPermission := AppPermission{}
|
||||
appPermissions := []AppPermission{}
|
||||
expiresAt := time.Time{}
|
||||
var expiresAt *time.Time
|
||||
svc.db.Where("app_id = ?", app.ID).Find(&appPermissions)
|
||||
|
||||
requestMethods := []string{}
|
||||
for _, appPerm := range appPermissions {
|
||||
if expiresAt.IsZero() && !appPerm.ExpiresAt.IsZero() {
|
||||
expiresAt = appPerm.ExpiresAt
|
||||
if !appPerm.ExpiresAt.IsZero() {
|
||||
expiresAt = &appPerm.ExpiresAt
|
||||
}
|
||||
if appPerm.RequestMethod == NIP_47_PAY_INVOICE_METHOD {
|
||||
//find the pay_invoice-specific permissions
|
||||
paySpecificPermission = appPerm
|
||||
}
|
||||
requestMethods = append(requestMethods, nip47MethodDescriptions[appPerm.RequestMethod])
|
||||
requestMethods = append(requestMethods, appPerm.RequestMethod)
|
||||
}
|
||||
|
||||
expiresAtFormatted := expiresAt.Format("January 2, 2006 03:04 PM")
|
||||
|
||||
renewsIn := ""
|
||||
//renewsIn := ""
|
||||
budgetUsage := int64(0)
|
||||
maxAmount := paySpecificPermission.MaxAmount
|
||||
if maxAmount > 0 {
|
||||
budgetUsage = svc.GetBudgetUsage(&paySpecificPermission)
|
||||
endOfBudget := GetEndOfBudget(paySpecificPermission.BudgetRenewal, app.CreatedAt)
|
||||
renewsIn = getEndOfBudgetString(endOfBudget)
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "apps/show.html", map[string]interface{}{
|
||||
"App": app,
|
||||
"PaySpecificPermission": paySpecificPermission,
|
||||
"RequestMethods": requestMethods,
|
||||
"ExpiresAt": expiresAt,
|
||||
"ExpiresAtFormatted": expiresAtFormatted,
|
||||
"User": user,
|
||||
"LastEvent": lastEvent,
|
||||
"EventsCount": eventsCount,
|
||||
"BudgetUsage": budgetUsage,
|
||||
"RenewsIn": renewsIn,
|
||||
"Csrf": csrf,
|
||||
})
|
||||
response := api.App{
|
||||
Name: app.Name,
|
||||
Description: app.Description,
|
||||
CreatedAt: app.CreatedAt,
|
||||
UpdatedAt: app.UpdatedAt,
|
||||
NostrPubkey: app.NostrPubkey,
|
||||
ExpiresAt: expiresAt,
|
||||
MaxAmount: maxAmount,
|
||||
RequestMethods: requestMethods,
|
||||
BudgetUsage: budgetUsage,
|
||||
BudgetRenewal: paySpecificPermission.BudgetRenewal,
|
||||
}
|
||||
|
||||
if lastEventResult.RowsAffected > 0 {
|
||||
response.LastEventAt = &lastEvent.CreatedAt
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
func getEndOfBudgetString(endOfBudget time.Time) (result string) {
|
||||
if endOfBudget.IsZero() {
|
||||
return "--"
|
||||
}
|
||||
endOfBudgetDuration := endOfBudget.Sub(time.Now())
|
||||
|
||||
//less than a day
|
||||
if endOfBudgetDuration.Hours() < 24 {
|
||||
hours := int(endOfBudgetDuration.Hours())
|
||||
minutes := int(endOfBudgetDuration.Minutes()) % 60
|
||||
return fmt.Sprintf("%d hours and %d minutes", hours, minutes)
|
||||
}
|
||||
//less than a month
|
||||
if endOfBudgetDuration.Hours() < 24*30 {
|
||||
days := int(endOfBudgetDuration.Hours() / 24)
|
||||
return fmt.Sprintf("%d days", days)
|
||||
}
|
||||
//more than a month
|
||||
months := int(endOfBudgetDuration.Hours() / 24 / 30)
|
||||
days := int(endOfBudgetDuration.Hours()/24) % 30
|
||||
if days > 0 {
|
||||
return fmt.Sprintf("%d months %d days", months, days)
|
||||
}
|
||||
return fmt.Sprintf("%d months", months)
|
||||
}
|
||||
|
||||
func (svc *Service) AppsNewHandler(c echo.Context) error {
|
||||
appName := c.QueryParam("name")
|
||||
if appName == "" {
|
||||
// c - for client (deprecated)
|
||||
appName = c.QueryParam("c")
|
||||
}
|
||||
pubkey := c.QueryParam("pubkey")
|
||||
returnTo := c.QueryParam("return_to")
|
||||
maxAmount := c.QueryParam("max_amount")
|
||||
budgetRenewal := strings.ToLower(c.QueryParam("budget_renewal"))
|
||||
expiresAt := c.QueryParam("expires_at") // YYYY-MM-DD or MM/DD/YYYY or timestamp in seconds
|
||||
if expiresAtTimestamp, err := strconv.Atoi(expiresAt); err == nil {
|
||||
expiresAt = time.Unix(int64(expiresAtTimestamp), 0).Format(time.RFC3339)
|
||||
}
|
||||
expiresAtISO, _ := time.Parse(time.RFC3339, expiresAt)
|
||||
expiresAtFormatted := expiresAtISO.Format("January 2, 2006 03:04 PM")
|
||||
|
||||
requestMethods := c.QueryParam("request_methods")
|
||||
customRequestMethods := requestMethods
|
||||
if requestMethods == "" {
|
||||
// if no request methods are given, enable them all by default
|
||||
keys := []string{}
|
||||
for key := range nip47MethodDescriptions {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
|
||||
requestMethods = strings.Join(keys, " ")
|
||||
}
|
||||
func (svc *Service) CSRFHandler(c echo.Context) error {
|
||||
csrf, _ := c.Get(middleware.DefaultCSRFConfig.ContextKey).(string)
|
||||
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
sess, _ := session.Get(CookieName, c)
|
||||
sess.Values["return_to"] = c.Path() + "?" + c.QueryString()
|
||||
sess.Options.MaxAge = 0
|
||||
sess.Options.SameSite = http.SameSiteLaxMode
|
||||
if svc.cfg.CookieDomain != "" {
|
||||
sess.Options.Domain = svc.cfg.CookieDomain
|
||||
}
|
||||
sess.Save(c.Request(), c.Response())
|
||||
return c.Redirect(302, fmt.Sprintf("/%s/auth?c=%s", strings.ToLower(svc.cfg.LNBackendType), appName))
|
||||
}
|
||||
|
||||
//construction to return a map with all possible permissions
|
||||
//and indicate which ones are checked by default in the front-end
|
||||
type RequestMethodHelper struct {
|
||||
Description string
|
||||
Icon string
|
||||
Checked bool
|
||||
}
|
||||
|
||||
requestMethodHelper := map[string]*RequestMethodHelper{}
|
||||
for k, v := range nip47MethodDescriptions {
|
||||
requestMethodHelper[k] = &RequestMethodHelper{
|
||||
Description: v,
|
||||
Icon: nip47MethodIcons[k],
|
||||
}
|
||||
}
|
||||
|
||||
for _, m := range strings.Split(requestMethods, " ") {
|
||||
if _, ok := nip47MethodDescriptions[m]; ok {
|
||||
requestMethodHelper[m].Checked = true
|
||||
}
|
||||
}
|
||||
|
||||
return c.Render(http.StatusOK, "apps/new.html", map[string]interface{}{
|
||||
"User": user,
|
||||
"Name": appName,
|
||||
"Pubkey": pubkey,
|
||||
"ReturnTo": returnTo,
|
||||
"MaxAmount": maxAmount,
|
||||
"BudgetRenewal": budgetRenewal,
|
||||
"ExpiresAt": expiresAt,
|
||||
"ExpiresAtFormatted": expiresAtFormatted,
|
||||
"RequestMethods": requestMethods,
|
||||
"CustomRequestMethods": customRequestMethods,
|
||||
"RequestMethodHelper": requestMethodHelper,
|
||||
"Csrf": csrf,
|
||||
})
|
||||
return c.JSON(http.StatusOK, csrf)
|
||||
}
|
||||
|
||||
func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
return err
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Bad arguments %s", err.Error()),
|
||||
})
|
||||
}
|
||||
if user == nil {
|
||||
return c.Redirect(302, "/")
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
|
||||
name := c.FormValue("name")
|
||||
|
|
@ -357,18 +200,25 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
decoded, err := hex.DecodeString(pairingPublicKey)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
svc.Logger.Errorf("Invalid public key format: %s", pairingPublicKey)
|
||||
return c.Redirect(302, "/apps")
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Invalid public key format: %s", pairingPublicKey),
|
||||
})
|
||||
}
|
||||
}
|
||||
app := App{Name: name, NostrPubkey: pairingPublicKey}
|
||||
maxAmount, _ := strconv.Atoi(c.FormValue("MaxAmount"))
|
||||
budgetRenewal := c.FormValue("BudgetRenewal")
|
||||
maxAmount, _ := strconv.Atoi(c.FormValue("maxAmount"))
|
||||
budgetRenewal := c.FormValue("budgetRenewal")
|
||||
|
||||
expiresAt := time.Time{}
|
||||
if c.FormValue("ExpiresAt") != "" {
|
||||
expiresAt, err = time.Parse(time.RFC3339, c.FormValue("ExpiresAt"))
|
||||
if c.FormValue("expiresAt") != "" {
|
||||
expiresAt, err = time.Parse(time.RFC3339, c.FormValue("expiresAt"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("Invalid ExpiresAt: %v", err)
|
||||
svc.Logger.Errorf("Invalid expiresAt: %s", pairingPublicKey)
|
||||
return c.JSON(http.StatusBadRequest, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Invalid expiresAt: %v", err),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -382,7 +232,7 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
requestMethods := c.FormValue("RequestMethods")
|
||||
requestMethods := c.FormValue("requestMethods")
|
||||
if requestMethods == "" {
|
||||
return fmt.Errorf("Won't create an app without request methods.")
|
||||
}
|
||||
|
|
@ -415,7 +265,10 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
"pairingPublicKey": pairingPublicKey,
|
||||
"name": name,
|
||||
}).Errorf("Failed to save app: %v", err)
|
||||
return c.Redirect(302, "/apps")
|
||||
return c.JSON(http.StatusInternalServerError, ErrorResponse{
|
||||
Error: true,
|
||||
Message: fmt.Sprintf("Failed to save app: %v", err),
|
||||
})
|
||||
}
|
||||
|
||||
publicRelayUrl := svc.cfg.PublicRelay
|
||||
|
|
@ -423,6 +276,11 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
publicRelayUrl = svc.cfg.Relay
|
||||
}
|
||||
|
||||
responseBody := &api.CreateAppResponse{}
|
||||
responseBody.Name = name
|
||||
responseBody.Pubkey = pairingPublicKey
|
||||
responseBody.PairingSecret = pairingSecretKey
|
||||
|
||||
if c.FormValue("returnTo") != "" {
|
||||
returnToUrl, err := url.Parse(c.FormValue("returnTo"))
|
||||
if err == nil {
|
||||
|
|
@ -433,7 +291,7 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
query.Add("lud16", user.LightningAddress)
|
||||
}
|
||||
returnToUrl.RawQuery = query.Encode()
|
||||
return c.Redirect(302, returnToUrl.String())
|
||||
responseBody.ReturnTo = returnToUrl.String()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -441,28 +299,23 @@ func (svc *Service) AppsCreateHandler(c echo.Context) error {
|
|||
if user.LightningAddress != "" {
|
||||
lud16 = fmt.Sprintf("&lud16=%s", user.LightningAddress)
|
||||
}
|
||||
pairingUri := template.URL(fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", svc.cfg.IdentityPubkey, publicRelayUrl, pairingSecretKey, lud16))
|
||||
return c.Render(http.StatusOK, "apps/create.html", map[string]interface{}{
|
||||
"User": user,
|
||||
"PairingUri": pairingUri,
|
||||
"PairingSecret": pairingSecretKey,
|
||||
"Pubkey": pairingPublicKey,
|
||||
"Name": name,
|
||||
})
|
||||
responseBody.PairingUri = fmt.Sprintf("nostr+walletconnect://%s?relay=%s&secret=%s%s", svc.cfg.IdentityPubkey, publicRelayUrl, pairingSecretKey, lud16)
|
||||
return c.JSON(http.StatusOK, responseBody)
|
||||
}
|
||||
|
||||
func (svc *Service) AppsDeleteHandler(c echo.Context) error {
|
||||
user, err := svc.GetUser(c)
|
||||
// TODO: error handling
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return c.Redirect(302, "/")
|
||||
return c.NoContent(http.StatusUnauthorized)
|
||||
}
|
||||
app := App{}
|
||||
svc.db.Where("user_id = ? AND nostr_pubkey = ?", user.ID, c.Param("pubkey")).First(&app)
|
||||
svc.db.Delete(&app)
|
||||
return c.Redirect(302, "/apps")
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (svc *Service) LogoutHandler(c echo.Context) error {
|
||||
|
|
@ -472,5 +325,27 @@ func (svc *Service) LogoutHandler(c echo.Context) error {
|
|||
sess.Options.Domain = svc.cfg.CookieDomain
|
||||
}
|
||||
sess.Save(c.Request(), c.Response())
|
||||
return c.Redirect(302, "/")
|
||||
return c.NoContent(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (svc *Service) InfoHandler(c echo.Context) error {
|
||||
responseBody := &api.InfoResponse{}
|
||||
responseBody.BackendType = svc.cfg.LNBackendType
|
||||
return c.JSON(http.StatusOK, responseBody)
|
||||
}
|
||||
|
||||
func (svc *Service) UserMeHandler(c echo.Context) error {
|
||||
user, err := svc.GetUser(c)
|
||||
if err != nil {
|
||||
// TODO: error handling
|
||||
return err
|
||||
}
|
||||
if user == nil {
|
||||
return c.NoContent(http.StatusNotFound)
|
||||
}
|
||||
|
||||
responseBody := api.User{
|
||||
Email: user.Email,
|
||||
}
|
||||
return c.JSON(http.StatusOK, responseBody)
|
||||
}
|
||||
|
|
|
|||
18
frontend/.eslintrc.cjs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
23
frontend/frontend.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package frontend
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo/v4"
|
||||
"github.com/labstack/echo/v4/middleware"
|
||||
)
|
||||
|
||||
func RegisterHandlers(e *echo.Echo) {
|
||||
e.Use(middleware.StaticWithConfig(middleware.StaticConfig{
|
||||
Skipper: nil,
|
||||
// Root directory from where the static content is served.
|
||||
Root: "frontend/dist",
|
||||
// Index file for serving a directory.
|
||||
// Optional. Default value "index.html".
|
||||
Index: "index.html",
|
||||
// Enable HTML5 mode by forwarding all not-found requests to root so that
|
||||
// SPA (single-page application) can handle the routing.
|
||||
HTML5: true,
|
||||
Browse: false,
|
||||
IgnoreBase: false,
|
||||
Filesystem: nil,
|
||||
}))
|
||||
}
|
||||
13
frontend/index.html
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Alby - Nostr Wallet Connect</title>
|
||||
<link rel="icon" href="/src/assets/images/nwc-logo.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
frontend/package.json
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-hot-toast": "^2.4.1",
|
||||
"react-qr-code": "^2.0.12",
|
||||
"react-router-dom": "^6.21.0",
|
||||
"swr": "^2.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@tailwindcss/typography": "^0.5.10",
|
||||
"@types/react": "^18.2.15",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@typescript-eslint/eslint-plugin": "^6.0.0",
|
||||
"@typescript-eslint/parser": "^6.0.0",
|
||||
"@vitejs/plugin-react-swc": "^3.3.2",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"eslint": "^8.45.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.3",
|
||||
"postcss": "^8.4.32",
|
||||
"tailwindcss": "^3.3.6",
|
||||
"typescript": "^5.0.2",
|
||||
"vite": "^4.4.5"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.js
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 54 KiB |
38
frontend/src/App.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
|
||||
|
||||
import About from "./screens/About";
|
||||
import AppsList from "./screens/apps/AppsList";
|
||||
import ShowApp from "./screens/apps/ShowApp";
|
||||
import Login from "./screens/Login";
|
||||
import NotFound from "./screens/NotFound";
|
||||
|
||||
import Navbar from "./components/Navbar";
|
||||
import Footer from "./components/Footer";
|
||||
import NewApp from "./screens/apps/NewApp";
|
||||
import AppCreated from "./screens/apps/AppCreated";
|
||||
import Toaster from "./components/Toast/Toaster";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<div className="dark:bg-black min-h-full">
|
||||
<Toaster />
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navbar />}>
|
||||
<Route index element={<Navigate to="/apps" replace />} />
|
||||
<Route path="apps" element={<AppsList />} />
|
||||
<Route path="apps/:pubkey" element={<ShowApp />} />
|
||||
<Route path="apps/new" element={<NewApp />} />
|
||||
<Route path="apps/created" element={<AppCreated />} />
|
||||
<Route path="about" element={<About />} />
|
||||
</Route>
|
||||
<Route path="login" element={<Login />} />
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Footer />
|
||||
</BrowserRouter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
1
frontend/src/assets/icons/TODO.txt
Normal file
|
|
@ -0,0 +1 @@
|
|||
Convert these to React components (see components/icons)
|
||||
|
Before Width: | Height: | Size: 344 B After Width: | Height: | Size: 344 B |
|
Before Width: | Height: | Size: 324 B After Width: | Height: | Size: 324 B |
|
Before Width: | Height: | Size: 73 KiB After Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 2 KiB After Width: | Height: | Size: 2 KiB |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
18
frontend/src/assets/images/logo.svg
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1210" height="525" fill="none">
|
||||
<circle r="36.7" fill="#000" transform="matrix(-1 0 0 1 94.1 138.1)" />
|
||||
<path stroke="#000" stroke-width="18.3" d="m88 132.6 68.5 68.5" />
|
||||
<circle cx="393.6" cy="138.1" r="36.7" fill="#000" />
|
||||
<path stroke="#000" stroke-width="18.3" d="m400.4 132.6-68.5 68.5" />
|
||||
<path fill="#FFDF6F" fill-rule="evenodd"
|
||||
d="M104.5 357a48.5 48.5 0 0 1-27.4-52.1c16.6-92.5 85.5-161.9 168-161.9 82.7 0 151.8 69.7 168.1 162.6a48.5 48.5 0 0 1-27.6 52 327.6 327.6 0 0 1-139.9 31.2c-50.5 0-98.4-11.4-141.2-31.8Z"
|
||||
clip-rule="evenodd" />
|
||||
<path fill="#000"
|
||||
d="m413.2 305.6-9 1.6 9-1.6Zm-27.6 52-4-8.3 4 8.3Zm-299.4-51c16-89.5 82-154.4 159-154.4v-18.3c-88.2 0-160 73.8-177.1 169.4l18 3.2Zm159-154.4c77 0 143.2 65.3 159 155l18-3.2c-16.8-96-88.8-170.1-177-170.1v18.3Zm136.5 197.1a318.4 318.4 0 0 1-136 30.3v18.3c51.4 0 100.1-11.5 143.8-32l-7.8-16.6Zm-136 30.3c-49.1 0-95.6-11.1-137.2-30.9l-7.9 16.6c44 21 93.2 32.6 145.1 32.6v-18.3Zm158.5-72.4c3 17.3-6.2 34.5-22.5 42.1l7.8 16.6c23.4-11 37.3-36 32.7-62l-18 3.3Zm-336.1-4a57.6 57.6 0 0 0 32.5 62l7.9-16.5a39.3 39.3 0 0 1-22.3-42.2L68 303.3Z" />
|
||||
<path fill="#000" fill-rule="evenodd"
|
||||
d="M139.8 334c-15.9-6.5-25.3-23.4-19.8-39.7 17-50.2 66.6-86.5 125.1-86.5s108.1 36.3 125.1 86.5c5.6 16.3-3.9 33.2-19.8 39.7a278.6 278.6 0 0 1-105.3 20.5c-37.2 0-72.8-7.3-105.3-20.5Z"
|
||||
clip-rule="evenodd" />
|
||||
<ellipse cx="287.3" cy="286.1" fill="#fff" rx="30.6" ry="24.4" />
|
||||
<ellipse cx="199.8" cy="286.1" fill="#fff" rx="30.6" ry="24.4" />
|
||||
<path fill="#000"
|
||||
d="M623.9 134.4h58.2l81.3 215-57.3 5.8-15.6-46h-75.2l-15 42.8h-58l81.6-217.6Zm50 129L652 204.2l-20.8 59.2h42.5ZM837.5 352h-58.2V129.6l58.2-3.5V352Zm61.4 0h-29.4V129.6l58.2-3.5v69.4c3.6-1.3 7.4-2.2 11.2-2.9a93.2 93.2 0 0 1 52.8 7.4 63.8 63.8 0 0 1 28.2 26.6c7 12 10.5 27.2 10.5 45.7a92 92 0 0 1-10.5 46.1 66 66 0 0 1-28.8 27.8 93.4 93.4 0 0 1-43 9.3 65.3 65.3 0 0 1-39.3-13.4L899 352Zm42.9-115.8a27 27 0 0 0-14.1 4.8v65.2c5.8 3 11.3 4.5 16.6 4.5a22 22 0 0 0 18.3-9.9 46 46 0 0 0 7.7-28.2c0-12-2.5-21-7.4-27.2-4.9-6.1-12-9.2-21.1-9.2ZM1088 352.6l1.3-3.5-55.7-142.7 53.8-15.4 31.7 93.5 29.7-87.7h58l-68.2 173.4c-3.2 8.4-8.4 16-15.7 23-7.3 7.1-15.7 13-25.3 18a128 128 0 0 1-29.7 10.9l-19.6-45.1 16-7c5.8-2.6 11-5.4 15.4-8.4 4.5-3 7.3-6 8.3-9Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
6
frontend/src/assets/images/nwc-white.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg class="mr-2" width="24" height="24" viewBox="0 0 24 24" fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd"
|
||||
d="M13.6281 5.03035L20.9945 12.43L20.9956 12.431C21.4028 12.84 21.8601 13.0639 22.279 13.1062C22.6711 13.1459 23.0667 13.0322 23.4219 12.6949L19.4029 2.22631C18.9491 1.2345 17.6421 0.998839 16.8705 1.7739L13.6281 5.03035ZM20.5966 12.8277L20.5967 12.8278L20.5965 12.828C21.5558 13.7914 22.8871 14.0212 23.8507 13.0622L20.5884 16.3091L19.0591 17.8312L17.9915 18.8938L16.1182 17.0137C17.026 15.6333 16.8781 13.76 15.6662 12.5406L14.3908 11.2573C14.2095 11.0748 13.9176 11.074 13.7351 11.2553L13.1055 11.8809L11.5093 10.2748C11.2323 9.99609 10.7744 10.0126 10.5176 10.3196C10.2928 10.5907 10.3315 10.9906 10.5786 11.2392L12.1549 12.8252L10.9035 14.0685L9.3073 12.4624C9.03028 12.1837 8.5705 12.1981 8.31561 12.5072C8.0928 12.7764 8.13155 13.1762 8.37864 13.4249L9.95488 15.0109L9.32725 15.6346C9.14479 15.8159 9.14392 16.1077 9.32522 16.2902L10.6006 17.5735C11.8123 18.7927 13.6847 18.9526 15.0709 18.054L16.9452 19.9351L16.3577 20.5197L14.3527 22.5153C13.3889 23.4743 11.8301 23.4707 10.8708 22.5072L0.717064 12.3077C-0.242097 11.344 -0.238627 9.78546 0.725161 8.82627L6.96087 2.61994C7.92465 1.66089 9.4834 1.66451 10.4427 2.62803L20.5966 12.8277Z"
|
||||
fill="currentColor" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
23
frontend/src/components/Footer.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import albyLogoWithText from "../assets/images/alby-logo-with-text.svg";
|
||||
import albyLogoWithTextDark from "../assets/images/alby-logo-with-text-dark.svg";
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
|
||||
function Footer() {
|
||||
const isDarkMode = useDarkMode();
|
||||
return (
|
||||
<footer className="mt-20 mb-4 flex justify-center items-center gap-2">
|
||||
<span className="text-gray-500 dark:text-neutral-300 text-xs">
|
||||
Made with 💜 by
|
||||
</span>
|
||||
<a href="https://getalby.com?utm_source=nwc" rel="noreferrer noopener">
|
||||
<img
|
||||
id="alby-logo"
|
||||
src={isDarkMode ? albyLogoWithTextDark : albyLogoWithText}
|
||||
className="w-16 inline"
|
||||
/>
|
||||
</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
export default Footer;
|
||||
28
frontend/src/components/Loading.tsx
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
function Loading({ color = "currentColor" }) {
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
className="animate-spin h-6 w-6 text-purple-700"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke={color}
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
fill={color}
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default Loading;
|
||||
155
frontend/src/components/Navbar.tsx
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import { Outlet, useLocation } from "react-router-dom";
|
||||
import { useInfo } from "../hooks/useInfo";
|
||||
import { useLogin } from "../hooks/useLogin";
|
||||
import nwcLogo from "../assets/images/nwc-logo.svg";
|
||||
import caretIcon from "../assets/icons/caret.svg";
|
||||
import aboutIcon from "../assets/icons/about.svg";
|
||||
import React from "react";
|
||||
import { LogoutIcon } from "./icons/LogoutIcon";
|
||||
import { handleFetchError, validateFetchResponse } from "../utils/fetch";
|
||||
import { useCSRF } from "../hooks/useCSRF";
|
||||
import { useUser } from "../hooks/useUser";
|
||||
|
||||
function Navbar() {
|
||||
const { data: info } = useInfo();
|
||||
const { data: user } = useUser();
|
||||
const location = useLocation();
|
||||
|
||||
const linkStyles =
|
||||
"text-gray-400 font-medium hover:text-gray-600 dark:hover:text-gray-300 transition";
|
||||
const selectedLinkStyles = "text-gray-900 dark:text-gray-100";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-gray-50 dark:bg-surface-00dp">
|
||||
<div className="bg-white border-b border-gray-200 dark:bg-surface-01dp dark:border-neutral-700 mb-6">
|
||||
<nav className="container max-w-screen-lg mx-auto px-4 lg:px-0 py-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center space-x-12 justify-center">
|
||||
<a
|
||||
href="/"
|
||||
className="font-headline text-[20px] dark:text-white flex gap-2 justify-center items-center"
|
||||
>
|
||||
<img
|
||||
alt="NWC Logo"
|
||||
className="w-8 inline"
|
||||
width="128"
|
||||
height="120"
|
||||
src={nwcLogo}
|
||||
/>
|
||||
<span className="dark:text-white text-lg font-semibold hidden sm:inline">
|
||||
Nostr Wallet Connect
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<div
|
||||
className={`${user ? "hidden md:flex" : "flex"} space-x-4`}
|
||||
>
|
||||
<a
|
||||
className={`${linkStyles} ${
|
||||
location.pathname === "/apps" && selectedLinkStyles
|
||||
}`}
|
||||
href="/apps"
|
||||
>
|
||||
Connections
|
||||
</a>
|
||||
<a
|
||||
className={`${linkStyles} ${
|
||||
location.pathname === "/about" && selectedLinkStyles
|
||||
}`}
|
||||
href="/about"
|
||||
>
|
||||
About
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{info?.backendType === "ALBY" && <ProfileDropdown />}
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center">
|
||||
<div className="container max-w-screen-lg">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfileDropdown() {
|
||||
useLogin();
|
||||
const { data: csrf } = useCSRF();
|
||||
const { data: user } = useUser();
|
||||
const [isOpen, setOpen] = React.useState(false);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
if (!csrf) {
|
||||
throw new Error("info not loaded");
|
||||
}
|
||||
const response = await fetch("/api/logout", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
});
|
||||
await validateFetchResponse(response);
|
||||
window.location.href = "/";
|
||||
} catch (error) {
|
||||
handleFetchError("Failed to logout", error);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: add a proper dropdown component
|
||||
return (
|
||||
<div className="flex items-center relative">
|
||||
<p
|
||||
className="text-gray-400 text-xs font-medium sm:text-base cursor-pointer select-none "
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span>{user.email}</span>
|
||||
<img
|
||||
id="caret"
|
||||
className={`inline cursor-pointer w-4 ml-2 ${
|
||||
isOpen ? "rotate-180" : ""
|
||||
}`}
|
||||
src={caretIcon}
|
||||
/>
|
||||
</p>
|
||||
|
||||
{isOpen && (
|
||||
<div
|
||||
className="font-medium flex flex-col px-4 w-40 logout absolute right top-8 right-0 justify-left cursor-pointer rounded-lg border border-gray-200 dark:border-gray-600 text-center bg-white dark:bg-surface-01dp shadow"
|
||||
id="dropdown"
|
||||
>
|
||||
<a
|
||||
className="md:hidden flex items-center justify-left py-2 text-gray-400 dark:text-gray-400"
|
||||
href="/about"
|
||||
>
|
||||
<img
|
||||
className="inline cursor-pointer w-4 mr-3"
|
||||
src={aboutIcon}
|
||||
alt="about-svg"
|
||||
/>
|
||||
<p className="font-normal">About</p>
|
||||
</a>
|
||||
|
||||
<div
|
||||
className="flex items-center justify-left py-2 text-red-500"
|
||||
onClick={logout}
|
||||
>
|
||||
<LogoutIcon className="inline cursor-pointer w-4 mr-3" />
|
||||
<p className="font-normal">Logout</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Navbar;
|
||||
38
frontend/src/components/QRCode.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import ReactQRCode from "react-qr-code";
|
||||
import { useDarkMode } from "../hooks/useDarkMode";
|
||||
|
||||
export type Props = {
|
||||
value: string;
|
||||
size?: number;
|
||||
className?: string;
|
||||
|
||||
// set the level to Q if there are overlays
|
||||
// Q will improve error correction (so we can add overlays covering up to 25% of the QR)
|
||||
// at the price of decreased information density (meaning the QR codes "pixels" have to be
|
||||
// smaller to encode the same information).
|
||||
// While that isn't that much of a problem for lightning addresses (because they are usually quite short),
|
||||
// for invoices that contain larger amount of data those QR codes can get "harder" to read.
|
||||
// (meaning you have to aim your phone very precisely and have to wait longer for the reader
|
||||
// to recognize the QR code)
|
||||
level?: "Q" | undefined;
|
||||
};
|
||||
|
||||
function QRCode({ value, size, level, className }: Props) {
|
||||
const isDarkMode = useDarkMode();
|
||||
console.log(isDarkMode);
|
||||
const fgColor = isDarkMode ? "#FFFFFF" : "#242424";
|
||||
const bgColor = isDarkMode ? "#242424" : "#FFFFFF";
|
||||
|
||||
return (
|
||||
<ReactQRCode
|
||||
value={value}
|
||||
size={size}
|
||||
fgColor={fgColor}
|
||||
bgColor={bgColor}
|
||||
className={`"w-full h-auto rounded-md" ${className}`}
|
||||
level={level}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QRCode;
|
||||
12
frontend/src/components/Toast/Toaster.tsx
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Toaster as HotToaster } from "react-hot-toast";
|
||||
|
||||
export default function Toaster() {
|
||||
return (
|
||||
<HotToaster
|
||||
position="bottom-center"
|
||||
toastOptions={{
|
||||
duration: 4_000,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
45
frontend/src/components/Toast/index.tsx
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import { ReactNode } from "react";
|
||||
import {
|
||||
CheckmarkIcon,
|
||||
ErrorIcon,
|
||||
ToastOptions,
|
||||
toast as hotToast,
|
||||
} from "react-hot-toast";
|
||||
|
||||
type ToastType = "error" | "success";
|
||||
|
||||
interface ToastMethods {
|
||||
success: (message: string | ReactNode, options?: ToastOptions) => void;
|
||||
error: (message: string | ReactNode, options?: ToastOptions) => void;
|
||||
custom: (
|
||||
children: ReactNode,
|
||||
type: ToastType,
|
||||
options?: ToastOptions
|
||||
) => void;
|
||||
}
|
||||
|
||||
const toast: ToastMethods = {
|
||||
success: (message: string | ReactNode, options?: ToastOptions) => {
|
||||
toast.custom(message, "success", options);
|
||||
},
|
||||
error: (message: string | ReactNode, options?: ToastOptions) => {
|
||||
toast.custom(message, "error", { duration: options?.duration ?? 8_000 });
|
||||
},
|
||||
custom: (children: ReactNode, type: ToastType, options?: ToastOptions) => {
|
||||
hotToast.custom(
|
||||
(t: { visible: boolean; id: string }) =>
|
||||
t.visible ? (
|
||||
<div className="bg-white dark:bg-surface-02dp px-4 py-3 drop-shadow-lg rounded-lg overflow-hidden flex flex-row items-center gap-3 text-gray-800 dark:text-neutral-200">
|
||||
<div className="shrink-0">
|
||||
{type == "success" && <CheckmarkIcon />}
|
||||
{type == "error" && <ErrorIcon />}
|
||||
</div>
|
||||
<div>{children}</div>
|
||||
</div>
|
||||
) : null,
|
||||
options
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default toast;
|
||||
21
frontend/src/components/icons/CheckIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function CheckIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M18.3814 5.35432C18.738 5.56495 18.8564 6.02484 18.6458 6.3815L11.5591 18.3815C11.4426 18.5787 11.2424 18.7119 11.0156 18.7431C10.7887 18.7743 10.56 18.7002 10.3946 18.5418L5.48126 13.8366C5.18211 13.5501 5.17184 13.0753 5.45833 12.7761C5.74482 12.477 6.21958 12.4667 6.51874 12.7532L10.7487 16.804L17.3542 5.61874C17.5648 5.26208 18.0247 5.14369 18.3814 5.35432Z"
|
||||
fill="black"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
17
frontend/src/components/icons/CopyIcon.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function CopyIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<rect width="10" height="14" x="6" y="6" rx="1.5" />
|
||||
<path d="M8.064 5.064A1.5 1.5 0 018.5 5h7A1.5 1.5 0 0117 6.5v11a1.5 1.5 0 01-.064.436A1.5 1.5 0 0018 16.5v-11A1.5 1.5 0 0016.5 4h-7a1.5 1.5 0 00-1.436 1.064z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
16
frontend/src/components/icons/EditIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function EditIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M3.995 17.207V19.5a.5.5 0 00.5.5h2.298a.5.5 0 00.353-.146l9.448-9.448-3-3-9.452 9.448a.5.5 0 00-.147.353zM14.832 6.167l3 3 1.46-1.46a1 1 0 000-1.414l-1.585-1.586a1 1 0 00-1.414 0l-1.46 1.46z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/icons/EyeIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function EyeIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M12 14a2 2 0 100-4 2 2 0 000 4z" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M21 12c0 2.761-4.03 5-9 5s-9-2.239-9-5 4.03-5 9-5 9 2.239 9 5zm-5 0a4 4 0 11-8 0 4 4 0 018 0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
27
frontend/src/components/icons/InvoiceIcon.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function InvoiceIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M7.5 4A1.5 1.5 0 006 5.5v13A1.5 1.5 0 007.5 20h9a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 4h-9zm6.854 4.354a.5.5 0 00-.708-.708l-4 4a.5.5 0 00.708.708l4-4zM11.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm2 4a1 1 0 100-2 1 1 0 000 2zm-5 2.5a.5.5 0 01.5-.5h6a.5.5 0 010 1H9a.5.5 0 01-.5-.5zm.5 1.5a.5.5 0 000 1h6a.5.5 0 000-1H9z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
></svg>;
|
||||
16
frontend/src/components/icons/LightningIcon.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function LightningIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path d="M18.496 10.709l-8.636 8.88c-.24.246-.638-.039-.482-.345l3.074-6.066a.3.3 0 00-.268-.436H5.718a.3.3 0 01-.214-.51l8.01-8.115c.232-.235.618.023.489.328L11.706 9.86a.3.3 0 00.28.417l6.291-.078a.3.3 0 01.22.509z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
21
frontend/src/components/icons/LogoIcon.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function LogoIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M13.6281 5.03035L20.9945 12.43L20.9956 12.431C21.4028 12.84 21.8601 13.0639 22.279 13.1062C22.6711 13.1459 23.0667 13.0322 23.4219 12.6949L19.4029 2.22631C18.9491 1.2345 17.6421 0.998839 16.8705 1.7739L13.6281 5.03035ZM20.5966 12.8277L20.5967 12.8278L20.5965 12.828C21.5558 13.7914 22.8871 14.0212 23.8507 13.0622L20.5884 16.3091L19.0591 17.8312L17.9915 18.8938L16.1182 17.0137C17.026 15.6333 16.8781 13.76 15.6662 12.5406L14.3908 11.2573C14.2095 11.0748 13.9176 11.074 13.7351 11.2553L13.1055 11.8809L11.5093 10.2748C11.2323 9.99609 10.7744 10.0126 10.5176 10.3196C10.2928 10.5907 10.3315 10.9906 10.5786 11.2392L12.1549 12.8252L10.9035 14.0685L9.3073 12.4624C9.03028 12.1837 8.5705 12.1981 8.31561 12.5072C8.0928 12.7764 8.13155 13.1762 8.37864 13.4249L9.95488 15.0109L9.32725 15.6346C9.14479 15.8159 9.14392 16.1077 9.32522 16.2902L10.6006 17.5735C11.8123 18.7927 13.6847 18.9526 15.0709 18.054L16.9452 19.9351L16.3577 20.5197L14.3527 22.5153C13.3889 23.4743 11.8301 23.4707 10.8708 22.5072L0.717064 12.3077C-0.242097 11.344 -0.238627 9.78546 0.725161 8.82627L6.96087 2.61994C7.92465 1.66089 9.4834 1.66451 10.4427 2.62803L20.5966 12.8277Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
36
frontend/src/components/icons/LogoutIcon.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function LogoutIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M19.2854 12.0002L11.2727 12.0002"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M16.5101 15.6364L19.9999 12L16.5101 8.36363"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
<path
|
||||
d="M13.4545 7V4H4V20H13.4545V17"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
18
frontend/src/components/icons/PlusIcon.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function PlusIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M12 3.25a.75.75 0 01.75.75v7.25H20a.75.75 0 010 1.5h-7.25V20a.75.75 0 01-1.5 0v-7.25H4a.75.75 0 010-1.5h7.25V4a.75.75 0 01.75-.75z"
|
||||
clipRule="evenodd"
|
||||
></path>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/icons/SearchIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function SearchIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 5.5a5 5 0 100 10 5 5 0 000-10zm-6.5 5a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M14.47 14.47a.75.75 0 011.06 0l4 4a.75.75 0 11-1.06 1.06l-4-4a.75.75 0 010-1.06z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
33
frontend/src/components/icons/TransactionsIcon.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function TransactionsIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<circle cx="5.5" cy="7.5" r="1.5" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 6.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zM8 8.5a.5.5 0 01.5-.5h6a.5.5 0 010 1h-6a.5.5 0 01-.5-.5z"
|
||||
/>
|
||||
<circle cx="5.5" cy="12" r="1.5" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 11a.5.5 0 01.5-.5h8a.5.5 0 010 1h-8A.5.5 0 018 11zM8 13a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7A.5.5 0 018 13z"
|
||||
/>
|
||||
<circle cx="5.5" cy="16.5" r="1.5" />
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M8 15.5a.5.5 0 01.5-.5H18a.5.5 0 010 1H8.5a.5.5 0 01-.5-.5zM8 17.5a.5.5 0 01.5-.5h4a.5.5 0 010 1h-4a.5.5 0 01-.5-.5z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
25
frontend/src/components/icons/WalletIcon.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { SVGAttributes } from "react";
|
||||
|
||||
export function WalletIcon(props: SVGAttributes<SVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M12 8a2 2 0 012-2h4a2 2 0 012 2v8a2 2 0 01-2 2h-4a2 2 0 01-2-2V8zm2-1a1 1 0 00-1 1v8a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1h-4z"
|
||||
/>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
clip-rule="evenodd"
|
||||
d="M5.5 6A1.5 1.5 0 004 7.5v9A1.5 1.5 0 005.5 18h10a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0015.5 6h-10zm2 7.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
51
frontend/src/fonts.css
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-named-instance: "Regular";
|
||||
src: url(/fonts/Inter-roman.var.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter var";
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
font-named-instance: "Italic";
|
||||
src: url(/fonts/Inter-italic.var.woff2) format("woff2");
|
||||
}
|
||||
|
||||
/* NOTE: source: https://gwfh.mranftl.com/fonts/work-sans?subsets=latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url("/fonts/work-sans-v18-latin-500.eot"); /* IE9 Compat Modes */
|
||||
src: url("/fonts/work-sans-v18-latin-500.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-IE8 */ url("/fonts/work-sans-v18-latin-500.woff2") format("woff2"),
|
||||
/* Super Modern Browsers */ url("/fonts/work-sans-v18-latin-500.woff")
|
||||
format("woff"),
|
||||
/* Modern Browsers */ url("/fonts/work-sans-v18-latin-500.ttf")
|
||||
format("truetype"),
|
||||
/* Safari, Android, iOS */
|
||||
url("/fonts/work-sans-v18-latin-500.svg#WorkSans") format("svg"); /* Legacy iOS */
|
||||
}
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: "Work Sans";
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url("/fonts/work-sans-v18-latin-600.eot"); /* IE9 Compat Modes */
|
||||
src: url("/fonts/work-sans-v18-latin-600.eot?#iefix")
|
||||
format("embedded-opentype"),
|
||||
/* IE6-IE8 */ url("/fonts/work-sans-v18-latin-600.woff2") format("woff2"),
|
||||
/* Super Modern Browsers */ url("/fonts/work-sans-v18-latin-600.woff")
|
||||
format("woff"),
|
||||
/* Modern Browsers */ url("/fonts/work-sans-v18-latin-600.ttf")
|
||||
format("truetype"),
|
||||
/* Safari, Android, iOS */
|
||||
url("/fonts/work-sans-v18-latin-600.svg#WorkSans") format("svg"); /* Legacy iOS */
|
||||
}
|
||||
7
frontend/src/hooks/useApp.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import useSWR from "swr";
|
||||
import { App } from "../types";
|
||||
import { swrFetcher } from "../swr";
|
||||
|
||||
export function useApp(pubkey: string | undefined) {
|
||||
return useSWR<App>(pubkey && `/api/apps/${pubkey}`, swrFetcher);
|
||||
}
|
||||
7
frontend/src/hooks/useApps.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import useSWR from "swr";
|
||||
import { App } from "../types";
|
||||
import { swrFetcher } from "../swr";
|
||||
|
||||
export function useApps() {
|
||||
return useSWR<App[]>("/api/apps", swrFetcher);
|
||||
}
|
||||
6
frontend/src/hooks/useCSRF.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import useSWR from "swr";
|
||||
import { swrFetcher } from "../swr";
|
||||
|
||||
export function useCSRF() {
|
||||
return useSWR<string>("/api/csrf", swrFetcher);
|
||||
}
|
||||
23
frontend/src/hooks/useDarkMode.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import React from "react";
|
||||
|
||||
export function useDarkMode() {
|
||||
const [isDarkMode, setIsDarkMode] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const darkModeMediaQuery = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)"
|
||||
);
|
||||
|
||||
const handleDarkModeChange = (event: MediaQueryListEvent) => {
|
||||
setIsDarkMode(event.matches);
|
||||
};
|
||||
|
||||
darkModeMediaQuery.addEventListener("change", handleDarkModeChange);
|
||||
setIsDarkMode(darkModeMediaQuery.matches);
|
||||
|
||||
return () => {
|
||||
darkModeMediaQuery.removeEventListener("change", handleDarkModeChange);
|
||||
};
|
||||
}, []);
|
||||
return isDarkMode;
|
||||
}
|
||||
7
frontend/src/hooks/useInfo.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import useSWR from "swr";
|
||||
import { InfoResponse } from "../types";
|
||||
import { swrFetcher } from "../swr";
|
||||
|
||||
export function useInfo() {
|
||||
return useSWR<InfoResponse>("/api/info", swrFetcher);
|
||||
}
|
||||
31
frontend/src/hooks/useLogin.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import React from "react";
|
||||
import { useUser } from "./useUser";
|
||||
|
||||
const RETURN_TO_KEY = "returnTo";
|
||||
|
||||
export function useLogin() {
|
||||
const { data: user, isLoading } = useUser();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (user) {
|
||||
const returnTo = window.localStorage.getItem(RETURN_TO_KEY);
|
||||
if (returnTo) {
|
||||
window.localStorage.removeItem(RETURN_TO_KEY);
|
||||
navigate(returnTo);
|
||||
}
|
||||
}
|
||||
if (!user && ["/login", "/about"].indexOf(location.pathname) < 0) {
|
||||
const returnTo = location.pathname + location.search;
|
||||
window.localStorage.setItem(RETURN_TO_KEY, returnTo);
|
||||
|
||||
navigate("/login");
|
||||
}
|
||||
}
|
||||
}, [user, isLoading, location.pathname, location.search, navigate]);
|
||||
|
||||
return null;
|
||||
}
|
||||
7
frontend/src/hooks/useUser.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import useSWR from "swr";
|
||||
import { swrFetcher } from "../swr";
|
||||
import { User } from "../types";
|
||||
|
||||
export function useUser() {
|
||||
return useSWR<User>("/api/user/me", swrFetcher);
|
||||
}
|
||||
15
frontend/src/index.css
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
}
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
11
frontend/src/main.tsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App.tsx";
|
||||
import "./index.css";
|
||||
import "./fonts.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
183
frontend/src/screens/About.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import nostrBitcoin from "../assets/images/nostr-bitcoin.png";
|
||||
|
||||
function About() {
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
|
||||
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
tl;dr
|
||||
</h3>
|
||||
<p className="leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect (NWC) is a way for applications like Nostr
|
||||
clients to access a remote Lightning wallet through a{" "}
|
||||
<a
|
||||
href="https://github.com/getAlby/nips/blob/master/47.md"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
standardized protocol
|
||||
</a>
|
||||
. For instance, you can link your{" "}
|
||||
<a
|
||||
href="https://getalby.com"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
Alby account
|
||||
</a>{" "}
|
||||
with any other application using this feature.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
|
||||
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
How to connect your app?
|
||||
</h3>
|
||||
<ol className="list-decimal list-inside leading-relaxed dark:text-neutral-400">
|
||||
<li className="mb-2">
|
||||
<strong className="dark:text-white">
|
||||
Create a new app connection
|
||||
</strong>
|
||||
<p className="ml-6">
|
||||
Enable the application by creating a permissioned connection to
|
||||
your wallet. This access can be revoked at any time.
|
||||
</p>
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong className="dark:text-white">Connect your app</strong>
|
||||
<p className="ml-6">
|
||||
Connect your application by adding the connection secret. Either
|
||||
by scanning the connect QR code or clicking a link.
|
||||
</p>
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
<strong className="dark:text-white">Done</strong>
|
||||
<p className="ml-6">
|
||||
Zapp and Boost! Your lightning wallet is available where you need
|
||||
it.
|
||||
<br />
|
||||
And if you ever want to disable it again, simply disconnect the
|
||||
connection again.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp">
|
||||
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
FAQs
|
||||
</h3>
|
||||
<h4 className="font-bold text-l font-headline mt-4 dark:text-white">
|
||||
How does it work?
|
||||
</h4>
|
||||
<p className="leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect uses the existing Nostr infrastructure to allow
|
||||
applications to communicate with lightning wallets. This makes it easy
|
||||
for developers to integrate and gives the users full flexibility on
|
||||
choice.
|
||||
<br />
|
||||
To learn more about the details have a look at
|
||||
<a
|
||||
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
the specs (NIP47)
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
|
||||
Which apps support it?
|
||||
</h4>
|
||||
<p className="leading-relaxed dark:text-neutral-400">
|
||||
<a
|
||||
href="https://linktr.ee/amethyst.social"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
Amethyst
|
||||
</a>
|
||||
is the first Nostr Client to fully support Nostr Wallet Connect. More
|
||||
are working on support for it and are following shortly.
|
||||
</p>
|
||||
|
||||
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
|
||||
Is it open source?
|
||||
</h4>
|
||||
<p className="leading-relaxed dark:text-neutral-400">
|
||||
Of course! Nostr Wallet Connect is an open standard and our
|
||||
implementation is
|
||||
<a
|
||||
href="https://github.com/getAlby/nostr-wallet-connect/"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
open source on GitHub
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<h4 className="font-bold text-l font-headline mt-6 dark:text-white">
|
||||
Can I run my own?
|
||||
</h4>
|
||||
<p className="leading-relaxed dark:text-neutral-400">
|
||||
Yes, Nostr Wallet Connect is for every wallet and can easily be
|
||||
self-hosted.
|
||||
<br />
|
||||
We are looking for contributors to add support for more wallet
|
||||
backends. If you're interested please contact us
|
||||
<a
|
||||
href="https://github.com/getAlby/nostr-wallet-connect/"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
on GitHub
|
||||
</a>
|
||||
or
|
||||
<a
|
||||
href="mailto:hello@getalby.com"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
hello@getalby.com
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp">
|
||||
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
For developers
|
||||
</h3>
|
||||
<p className="mb-1 leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect is an open protocol enabling applications to
|
||||
interact with bitcoin lightning wallets. It allows users to connect
|
||||
their existing wallets to your application allowing you to easily
|
||||
integrate bitcoin lightning functionality but still focus on the core
|
||||
functionality of your app.
|
||||
</p>
|
||||
|
||||
<ul className="list-inside list-disc mb-8 dark:text-neutral-400">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/getAlby/nips/blob/master/47.md"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
Read the specs (NIP47)
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation"
|
||||
className="dark:text-gray-300 underline"
|
||||
>
|
||||
Look at the JavaScript SDK
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p className="sm:text-2xl font-bold font-headline mb-4 text-center dark:text-white">
|
||||
Happy zapping!
|
||||
<img
|
||||
src={nostrBitcoin}
|
||||
alt="Bitcoin on Nostr"
|
||||
className="w-80 mx-auto"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default About;
|
||||
54
frontend/src/screens/Login.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import nwcLogo from "../assets/images/nwc-logo.svg";
|
||||
import albyHead from "../assets/images/alby-head.svg";
|
||||
import { useInfo } from "../hooks/useInfo";
|
||||
|
||||
function Login() {
|
||||
const { data: info } = useInfo();
|
||||
const isAlby = info?.backendType === "ALBY";
|
||||
|
||||
return (
|
||||
<div className="text-center pt-4">
|
||||
<img
|
||||
alt="Nostr Wallet Connect logo"
|
||||
className="mx-auto my-4"
|
||||
width="128"
|
||||
height="120"
|
||||
src={nwcLogo}
|
||||
/>
|
||||
|
||||
<h1 className="font-headline text-3xl sm:text-4xl mb-2 dark:text-white">
|
||||
Nostr Wallet Connect
|
||||
</h1>
|
||||
|
||||
<h2 className="text-md text-gray-700 dark:text-neutral-300">
|
||||
Securely connect your {isAlby ? "Alby Account" : "LND Wallet"} to Nostr
|
||||
clients and applications.
|
||||
</h2>
|
||||
|
||||
{isAlby && (
|
||||
<p className="my-8">
|
||||
<a
|
||||
className=" inline-flex cursor-pointer items-center justify-center rounded-md transition-all px-10 py-4 text-black bg-gradient-to-r from-[#ffde6e] to-[#f8c455]"
|
||||
href="/alby/auth"
|
||||
>
|
||||
<img
|
||||
src={albyHead}
|
||||
width="400"
|
||||
height="400"
|
||||
className="w-[24px] mr-2"
|
||||
/>
|
||||
Log in with Alby Account
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p>
|
||||
<a href="/about" className="text-purple-700 dark:text-purple-400">
|
||||
How does it work?
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Login;
|
||||
17
frontend/src/screens/NotFound.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
function NotFound() {
|
||||
return (
|
||||
<div className="bg-white rounded-md shadow px-4 lg:p-8 dark:bg-surface-02dp">
|
||||
<h3 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
404 Page Not Found
|
||||
</h3>
|
||||
<p className="leading-relaxed dark:text-neutral-400 mb-4">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
<a href="/" className="text-purple-700 dark:text-purple-400">
|
||||
Return Home
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default NotFound;
|
||||
146
frontend/src/screens/apps/AppCreated.tsx
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useLocation, Navigate } from "react-router-dom";
|
||||
import { CreateAppResponse } from "../../types";
|
||||
|
||||
import { EyeIcon } from "../../components/icons/EyeIcon";
|
||||
import { CopyIcon } from "../../components/icons/CopyIcon";
|
||||
import { LogoIcon } from "../../components/icons/LogoIcon";
|
||||
import QRCode from "../../components/QRCode";
|
||||
|
||||
export default function AppCreated() {
|
||||
const { state } = useLocation();
|
||||
const createAppResponse = state as CreateAppResponse;
|
||||
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isPopupVisible, setPopupVisible] = useState(false);
|
||||
const popupRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
popupRef.current &&
|
||||
!popupRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setPopupVisible(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handleClickOutside);
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!createAppResponse) {
|
||||
return <Navigate to="/apps/new" state={{ from: "/apps/success" }} />;
|
||||
}
|
||||
|
||||
const pairingUri = createAppResponse.pairingUri;
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(pairingUri);
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea");
|
||||
textArea.value = pairingUri;
|
||||
textArea.style.position = "absolute";
|
||||
textArea.style.opacity = "0";
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
new Promise((res, rej) => {
|
||||
document.execCommand("copy") ? res(pairingUri) : rej();
|
||||
textArea.remove();
|
||||
});
|
||||
}
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
const togglePopup = () => {
|
||||
setPopupVisible(!isPopupVisible);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-screen-sm mx-auto">
|
||||
<h2 className="font-bold text-2xl font-headline mb-2 dark:text-white text-center">
|
||||
🚀 Almost there!
|
||||
</h2>
|
||||
<div className="font-medium text-center mb-8 dark:text-white">
|
||||
Complete the last step of the setup by pasting or scanning your
|
||||
connection's pairing secret in the desired app to finalise the
|
||||
connection.
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<a
|
||||
href={pairingUri}
|
||||
className="w-full inline-flex bg-purple-700 cursor-pointer duration-150 focus:outline-none hover:bg-purple-900 items-center justify-center px-5 py-4 rounded-md shadow text-white transition mb-2"
|
||||
>
|
||||
<LogoIcon className="inline w-6 mr-2" />
|
||||
<p className="font-medium">Open in supported app</p>
|
||||
</a>
|
||||
<div className="text-center text-xs text-gray-600 dark:text-neutral-500">
|
||||
Only connect with apps you trust!
|
||||
</div>
|
||||
|
||||
<div className="dark:text-white text-sm text-center mt-8 mb-1">
|
||||
Manually pair app ↓
|
||||
</div>
|
||||
<button
|
||||
id="copy-button"
|
||||
className={`w-full inline-flex items-center justify-center px-3 py-2 cursor-pointer duration-150 transition border dark:border-white/10 ${
|
||||
copied
|
||||
? "bg-green-600 text-white"
|
||||
: "bg-white dark:bg-surface-02dp text-purple-700 dark:text-neutral-200 hover:bg-gray-50 dark:hover:bg-surface-16dp"
|
||||
} bg-origin-border rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 my-2`}
|
||||
onClick={copyToClipboard}
|
||||
>
|
||||
<CopyIcon className="inline w-6 mr-2" />
|
||||
<span id="copy-text">
|
||||
{copied ? "Copied to clipboard!" : "Copy pairing secret"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
id="copy-button"
|
||||
className={`w-full inline-flex items-center justify-center px-3 py-2 cursor-pointer duration-150 transition border dark:border-white/10 bg-white dark:bg-surface-02dp text-purple-700 dark:text-neutral-200 hover:bg-gray-50 dark:hover:bg-surface-16dp bg-origin-border rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 mb-2`}
|
||||
onClick={togglePopup}
|
||||
>
|
||||
<EyeIcon className="inline w-6 mr-2" />
|
||||
<span id="copy-text">QR Code</span>
|
||||
</button>
|
||||
{/* ... Remaining JSX conversion for QR code and other elements */}
|
||||
</div>
|
||||
<div
|
||||
className={`fixed inset-0 items-center justify-center ${
|
||||
isPopupVisible ? "flex" : "hidden"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={togglePopup}
|
||||
className="fixed inset-0 bg-gray-900 opacity-50"
|
||||
></div>
|
||||
<div className="bg-white dark:bg-surface-02dp p-4 lg:px-6 rounded shadow relative">
|
||||
<h2 className="mb-4 font-semibold text-lg lg:text-xl font-headline dark:text-white">
|
||||
Scan QR Code in the app to pair
|
||||
</h2>
|
||||
<a
|
||||
href={pairingUri}
|
||||
target="_blank"
|
||||
className="block border-4 border-purple-600 rounded-lg p-4 lg:p-6"
|
||||
>
|
||||
<QRCode value={pairingUri} size={256} />
|
||||
</a>
|
||||
<button
|
||||
onClick={togglePopup}
|
||||
className="w-full inline-flex font-semibold items-center justify-center px-3 py-2 cursor-pointer duration-150 transition bg-white text-gray-700 dark:bg-surface-02dp dark:text-neutral-200 dark:border-neutral-800 hover:bg-gray-50 dark:hover:bg-surface-16dp bg-origin-border shadow rounded-md focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-purple-700 mt-4"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
81
frontend/src/screens/apps/AppsList.tsx
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import Loading from "../../components/Loading";
|
||||
import { useApps } from "../../hooks/useApps";
|
||||
import { PlusIcon } from "../../components/icons/PlusIcon";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
function AppsList() {
|
||||
const { data: apps } = useApps();
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!apps) {
|
||||
return <Loading />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-between items-center">
|
||||
<h2 className="font-bold text-2xl font-headline dark:text-white">
|
||||
Connected apps
|
||||
</h2>
|
||||
<a
|
||||
className="inline-flex bg-purple-700 cursor-pointer dark:text-neutral-200 duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium items-center justify-center px-3 md:px-6 py-2 md:py-3 rounded-lg shadow text-white transition {{if not .User}}opacity-50{{else}}hover:bg-purple-900{{end}} text-sm md:text-base"
|
||||
href="/apps/new"
|
||||
>
|
||||
<PlusIcon className="mr-2 text-white w-6 h-6" />
|
||||
Connect app
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<table className="table-fixed w-full text-sm text-left">
|
||||
<thead className="text-xs text-gray-900 uppercase bg-gray-50 dark:bg-surface-08dp dark:text-white rounded-t-lg">
|
||||
<tr>
|
||||
<th scope="col" className="px-6 py-3 w-full">
|
||||
Name
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-40 hidden md:table-cell">
|
||||
Last used
|
||||
</th>
|
||||
<th scope="col" className="px-6 py-3 w-24"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y dark:divide-white/10">
|
||||
{!apps.length && (
|
||||
<tr className="bg-white dark:bg-surface-02dp">
|
||||
<td
|
||||
colSpan={3}
|
||||
className="px-6 py-16 text-center text-gray-500 dark:text-neutral-400"
|
||||
>
|
||||
No apps connected yet.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{apps.map((app, index) => (
|
||||
<tr
|
||||
key={index}
|
||||
onClick={() => navigate(`/apps/${app.nostrPubkey}`)}
|
||||
className="bg-white dark:bg-surface-02dp cursor-pointer hover:bg-purple-50 dark:hover:bg-surface-16dp"
|
||||
>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-white">
|
||||
{app.name}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-gray-500 dark:text-neutral-400 hidden md:table-cell">
|
||||
{app.lastEventAt
|
||||
? new Date(app.lastEventAt).toLocaleDateString()
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="px-6 py-4 text-purple-700 dark:text-purple-400 text-right">
|
||||
<a href={`/apps/${app.nostrPubkey}`}>Details</a>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppsList;
|
||||
400
frontend/src/screens/apps/NewApp.tsx
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
import { useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
import toast from "react-hot-toast";
|
||||
import {
|
||||
BudgetRenewalType,
|
||||
CreateAppResponse,
|
||||
IconMap,
|
||||
NIP_47_GET_BALANCE_METHOD,
|
||||
NIP_47_GET_INFO_METHOD,
|
||||
NIP_47_LIST_TRANSACTIONS_METHOD,
|
||||
NIP_47_LOOKUP_INVOICE_METHOD,
|
||||
NIP_47_MAKE_INVOICE_METHOD,
|
||||
NIP_47_PAY_INVOICE_METHOD,
|
||||
RequestMethodType,
|
||||
nip47MethodDescriptions,
|
||||
validBudgetRenewals,
|
||||
} from "../../types";
|
||||
import { handleFetchError, validateFetchResponse } from "../../utils/fetch";
|
||||
import { useCSRF } from "../../hooks/useCSRF";
|
||||
import { WalletIcon } from "../../components/icons/WalletIcon";
|
||||
import { LightningIcon } from "../../components/icons/LightningIcon";
|
||||
import { InvoiceIcon } from "../../components/icons/InvoiceIcon";
|
||||
import { SearchIcon } from "../../components/icons/SearchIcon";
|
||||
import { TransactionsIcon } from "../../components/icons/TransactionsIcon";
|
||||
import { EditIcon } from "../../components/icons/EditIcon";
|
||||
import { useUser } from "../../hooks/useUser";
|
||||
|
||||
const NewApp = () => {
|
||||
const { data: csrf } = useCSRF();
|
||||
const { data: user } = useUser();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
|
||||
const [edit, setEdit] = useState(false);
|
||||
|
||||
const nameParam = (queryParams.get("name") || queryParams.get("c")) ?? "";
|
||||
const [appName, setAppName] = useState(nameParam);
|
||||
const pubkey = queryParams.get("pubkey") ?? "";
|
||||
const returnTo = queryParams.get("return_to") ?? "";
|
||||
|
||||
const budgetRenewalParam = queryParams.get(
|
||||
"budget_renewal"
|
||||
) as BudgetRenewalType;
|
||||
const budgetRenewal: BudgetRenewalType = validBudgetRenewals.includes(
|
||||
budgetRenewalParam
|
||||
)
|
||||
? budgetRenewalParam
|
||||
: "monthly";
|
||||
|
||||
const reqMethodsParam = queryParams.get("request_methods");
|
||||
const [requestMethods, setRequestMethods] = useState(
|
||||
reqMethodsParam ?? Object.keys(nip47MethodDescriptions).join(" ")
|
||||
);
|
||||
|
||||
const maxAmountParam = queryParams.get("max_amount") ?? "";
|
||||
const [maxAmount, setMaxAmount] = useState(
|
||||
parseInt(maxAmountParam || "100000")
|
||||
);
|
||||
|
||||
const parseExpiresParam = (expiresParam: string): string => {
|
||||
if (/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/.test(expiresParam)) {
|
||||
const d = new Date(expiresParam);
|
||||
const isIso =
|
||||
d instanceof Date &&
|
||||
!isNaN(d.getTime()) &&
|
||||
d.toISOString() === expiresAtParam;
|
||||
if (isIso) {
|
||||
return expiresParam;
|
||||
}
|
||||
}
|
||||
if (!isNaN(parseInt(expiresParam))) {
|
||||
return new Date(parseInt(expiresAtParam as string) * 1000).toISOString();
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
// Only timestamp in seconds or ISO string is expected
|
||||
const expiresAtParam = parseExpiresParam(queryParams.get("expires_at") ?? "");
|
||||
const [expiresAt, setExpiresAt] = useState(expiresAtParam ?? "");
|
||||
const [days, setDays] = useState(0);
|
||||
const [expireOptions, setExpireOptions] = useState(false);
|
||||
|
||||
const today = new Date();
|
||||
const handleDays = (days: number) => {
|
||||
setDays(days);
|
||||
if (!days) {
|
||||
setExpiresAt("");
|
||||
return;
|
||||
}
|
||||
const expiryDate = new Date(today.getTime() + days * 24 * 60 * 60 * 1000);
|
||||
setExpiresAt(expiryDate.toISOString());
|
||||
};
|
||||
|
||||
const handleRequestMethodChange = (
|
||||
event: React.ChangeEvent<HTMLInputElement>
|
||||
) => {
|
||||
const rm = event.target.value;
|
||||
if (requestMethods.includes(rm)) {
|
||||
// If checked and item is already in the list, remove it
|
||||
const newMethods = requestMethods
|
||||
.split(" ")
|
||||
.filter((reqMethod) => reqMethod !== rm)
|
||||
.join(" ");
|
||||
setRequestMethods(newMethods);
|
||||
} else {
|
||||
setRequestMethods(`${requestMethods} ${rm}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
if (!csrf) {
|
||||
throw new Error("No CSRF token");
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("name", appName);
|
||||
formData.append("maxAmount", maxAmount.toString());
|
||||
formData.append("budgetRenewal", budgetRenewal);
|
||||
formData.append("expiresAt", expiresAt);
|
||||
formData.append("requestMethods", requestMethods);
|
||||
formData.append("pubkey", pubkey);
|
||||
formData.append("returnTo", returnTo);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/apps", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
body: formData,
|
||||
|
||||
// TODO: send consider sending JSON data
|
||||
/*body: JSON.stringify({
|
||||
name: appName,
|
||||
pubkey: pubkey,
|
||||
maxAmount: maxAmount.toString(),
|
||||
budgetRenewal: budgetRenewal,
|
||||
expiresAt: expiresAt,
|
||||
requestMethods: requestMethods,
|
||||
returnTo: returnTo,
|
||||
}),*/
|
||||
});
|
||||
await validateFetchResponse(response);
|
||||
|
||||
const createAppResponse: CreateAppResponse = await response.json();
|
||||
navigate("/apps/created", {
|
||||
state: createAppResponse,
|
||||
});
|
||||
toast.success("App created!");
|
||||
} catch (error) {
|
||||
handleFetchError("Failed to create app", error);
|
||||
}
|
||||
};
|
||||
|
||||
const iconMap: IconMap = {
|
||||
[NIP_47_GET_BALANCE_METHOD]: WalletIcon,
|
||||
[NIP_47_GET_INFO_METHOD]: WalletIcon,
|
||||
[NIP_47_LIST_TRANSACTIONS_METHOD]: TransactionsIcon,
|
||||
[NIP_47_LOOKUP_INVOICE_METHOD]: SearchIcon,
|
||||
[NIP_47_MAKE_INVOICE_METHOD]: InvoiceIcon,
|
||||
[NIP_47_PAY_INVOICE_METHOD]: LightningIcon,
|
||||
};
|
||||
|
||||
const expiryOptions: Record<string, number> = {
|
||||
"1 week": 7,
|
||||
"1 month": 30,
|
||||
"1 year": 365,
|
||||
Never: 0,
|
||||
};
|
||||
|
||||
const budgetOptions: Record<string, number> = {
|
||||
"10k": 10_000,
|
||||
"25k": 25_000,
|
||||
"50k": 50_000,
|
||||
"100k": 100_000,
|
||||
"1M": 100_000_000,
|
||||
Unlimited: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl font-headline mb-4 dark:text-white">
|
||||
{nameParam ? `Connect to ${appName}` : "Connect a new app"}
|
||||
</h2>
|
||||
|
||||
<form onSubmit={handleSubmit} acceptCharset="UTF-8">
|
||||
<div className="bg-white dark:bg-surface-02dp rounded-md shadow p-4 md:p-8">
|
||||
{!nameParam && (
|
||||
<>
|
||||
<label
|
||||
htmlFor="name"
|
||||
className="block font-medium text-gray-900 dark:text-white"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
readOnly={!!nameParam}
|
||||
type="text"
|
||||
name="name"
|
||||
value={appName}
|
||||
id="name"
|
||||
onChange={(e) => setAppName(e.target.value)}
|
||||
required
|
||||
autoComplete="off"
|
||||
className="bg-gray-50 border border-gray-300 text-gray-900 focus:ring-purple-700 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 text-sm rounded-lg block w-full p-2.5 dark:bg-surface-00dp dark:border-gray-700 dark:placeholder-gray-400 dark:text-white"
|
||||
/>
|
||||
<p className="mt-1 mb-6 text-xs text-gray-500 dark:text-gray-400">
|
||||
Name of the app or purpose of the connection
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-between items-center mb-2 text-gray-800 dark:text-white">
|
||||
<p className="text-lg font-medium">Authorize the app to:</p>
|
||||
{!reqMethodsParam && (
|
||||
<EditIcon
|
||||
onClick={() => setEdit(true)}
|
||||
className="text-gray-800 dark:text-gray-300 cursor-pointer w-6"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<ul className="flex flex-col w-full">
|
||||
{Object.keys(nip47MethodDescriptions).map((rm, index) => {
|
||||
const RequestMethodIcon = iconMap[rm as RequestMethodType];
|
||||
return (
|
||||
<li
|
||||
key={index}
|
||||
className={`w-full ${
|
||||
rm == "pay_invoice" ? "order-last" : ""
|
||||
} ${!edit && !requestMethods.includes(rm) ? "hidden" : ""}`}
|
||||
>
|
||||
<div className="flex items-center mb-2">
|
||||
{RequestMethodIcon && (
|
||||
<RequestMethodIcon
|
||||
className={`text-gray-800 dark:text-gray-300 w-5 mr-3 ${
|
||||
edit ? "hidden" : ""
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="checkbox"
|
||||
id={rm}
|
||||
value={rm}
|
||||
checked={requestMethods.includes(rm)}
|
||||
onChange={handleRequestMethodChange}
|
||||
className={` ${
|
||||
!edit ? "hidden" : ""
|
||||
} w-4 h-4 mr-4 text-purple-700 bg-gray-50 border border-gray-300 rounded focus:ring-purple-700 dark:focus:ring-purple-600 dark:ring-offset-gray-800 focus:ring-2 dark:bg-surface-00dp dark:border-gray-700 cursor-pointer`}
|
||||
/>
|
||||
<label
|
||||
htmlFor={rm}
|
||||
className="text-gray-800 dark:text-gray-300 cursor-pointer"
|
||||
>
|
||||
{nip47MethodDescriptions[rm as RequestMethodType]}
|
||||
</label>
|
||||
</div>
|
||||
{rm == "pay_invoice" && (
|
||||
<div
|
||||
className={`pt-2 pb-2 pl-5 ml-2.5 border-l-2 border-l-gray-200 dark:border-l-gray-400 ${
|
||||
!requestMethods.includes(rm)
|
||||
? edit
|
||||
? "pointer-events-none opacity-30"
|
||||
: "hidden"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{!maxAmountParam ? (
|
||||
<>
|
||||
<p className="text-gray-600 dark:text-gray-300 mb-2 text-sm">
|
||||
Monthly budget
|
||||
</p>
|
||||
<div
|
||||
id="budget-allowance-limits"
|
||||
className="grid grid-cols-6 grid-rows-2 md:grid-rows-1 md:grid-cols-6 gap-2 text-xs text-gray-800 dark:text-neutral-200"
|
||||
>
|
||||
{Object.keys(budgetOptions).map((budget) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={() =>
|
||||
setMaxAmount(budgetOptions[budget])
|
||||
}
|
||||
className={`col-span-2 md:col-span-1 cursor-pointer rounded border-2 ${
|
||||
maxAmount == budgetOptions[budget]
|
||||
? "border-purple-700 dark:border-purple-300 text-purple-700 bg-purple-100 dark:bg-purple-900"
|
||||
: "border-gray-200 dark:border-gray-400"
|
||||
} text-center py-4 dark:text-white`}
|
||||
>
|
||||
{budget}
|
||||
<br />
|
||||
{budgetOptions[budget]
|
||||
? "sats"
|
||||
: "#reckless"}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
<span className="capitalize">
|
||||
{budgetRenewal}
|
||||
</span>{" "}
|
||||
budget: {maxAmount} sats
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{!expiresAtParam ? (
|
||||
<>
|
||||
<div
|
||||
onClick={() => setExpireOptions(true)}
|
||||
className={`${
|
||||
expireOptions ? "hidden" : ""
|
||||
} cursor-pointer text-sm font-medium text-purple-700 dark:text-purple-500`}
|
||||
>
|
||||
+ Add connection expiry time
|
||||
</div>
|
||||
|
||||
{expireOptions && (
|
||||
<div className="text-gray-800 dark:text-neutral-200">
|
||||
<p className="text-lg font-medium mb-2">
|
||||
Connection expiry time
|
||||
</p>
|
||||
<div
|
||||
id="expiry-days"
|
||||
className="grid grid-cols-4 gap-2 text-xs"
|
||||
>
|
||||
{Object.keys(expiryOptions).map((expiry) => {
|
||||
return (
|
||||
<div
|
||||
onClick={() => handleDays(expiryOptions[expiry])}
|
||||
className={`cursor-pointer rounded border-2 ${
|
||||
days == expiryOptions[expiry]
|
||||
? "border-purple-700 dark:border-purple-300 text-purple-700 bg-purple-100 dark:bg-purple-900"
|
||||
: "border-gray-200 dark:border-gray-400"
|
||||
} text-center py-4`}
|
||||
>
|
||||
{expiry}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-lg font-medium mb-2 text-gray-800 dark:text-neutral-200">
|
||||
Connection expiry time
|
||||
</p>
|
||||
<p className="text-gray-600 dark:text-gray-300 text-sm">
|
||||
{new Date(parseInt(expiresAtParam) * 1000).toISOString()}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{user && user.email && (
|
||||
<p className="mt-8 pt-4 border-t border-gray-300 dark:border-gray-700 text-sm text-gray-500 dark:text-neutral-300 text-center">
|
||||
You're logged in as{" "}
|
||||
<span className="font-mono">{user.email}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col sm:flex-row sm:justify-center">
|
||||
{!pubkey && (
|
||||
<a
|
||||
href="/apps"
|
||||
className="inline-flex p-4 underline cursor-pointer duration-150 items-center justify-center text-gray-700 dark:text-neutral-300 w-full sm:w-[250px] order-last sm:order-first"
|
||||
>
|
||||
Cancel
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex w-full sm:w-[250px] bg-purple-700 cursor-pointer dark:text-neutral-200 duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium hover:bg-purple-900 items-center justify-center px-5 py-3 rounded-md shadow text-white transition"
|
||||
>
|
||||
{pubkey ? "Connect" : "Next"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NewApp;
|
||||
142
frontend/src/screens/apps/ShowApp.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import Loading from "../../components/Loading";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import { useInfo } from "../../hooks/useInfo";
|
||||
import { useApp } from "../../hooks/useApp";
|
||||
import { handleFetchError, validateFetchResponse } from "../../utils/fetch";
|
||||
import toast from "../../components/Toast";
|
||||
import { useCSRF } from "../../hooks/useCSRF";
|
||||
import { RequestMethodType, nip47MethodDescriptions } from "../../types";
|
||||
|
||||
function ShowApp() {
|
||||
const { data: info } = useInfo();
|
||||
const { data: csrf } = useCSRF();
|
||||
const { pubkey } = useParams() as { pubkey: string };
|
||||
const { data: app } = useApp(pubkey);
|
||||
const navigate = useNavigate();
|
||||
|
||||
if (!app || !info) {
|
||||
return <Loading />;
|
||||
}
|
||||
const handleDelete = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (!csrf) {
|
||||
throw new Error("No CSRF token");
|
||||
}
|
||||
const response = await fetch(`/api/apps/${app.nostrPubkey}`, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrf,
|
||||
},
|
||||
});
|
||||
await validateFetchResponse(response);
|
||||
navigate("/apps");
|
||||
toast.success("App disconnected");
|
||||
} catch (error) {
|
||||
await handleFetchError("Failed to delete app", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h2 className="font-bold text-2xl font-headline dark:text-white">
|
||||
{app ? app.name : "Fetching app..."}
|
||||
</h2>
|
||||
<p className="text-gray-600 dark:text-neutral-400 text-sm">
|
||||
{app ? app.description : ""}
|
||||
</p>
|
||||
<a className="ml-1 mt-1 mb-4 block dark:text-white text-xs" href="/apps">
|
||||
{"<"} Back to overview
|
||||
</a>
|
||||
|
||||
<div className="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp">
|
||||
<div className="divide-y divide-gray-200 dark:divide-white/10 dark:bg-surface-02dp">
|
||||
<div className="pb-4">
|
||||
<table>
|
||||
<tr>
|
||||
<td className="align-top w-32 font-medium dark:text-white">
|
||||
Public Key
|
||||
</td>
|
||||
<td className="text-gray-600 dark:text-neutral-400 break-all">
|
||||
{app.nostrPubkey}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="align-top font-medium dark:text-white">
|
||||
Last used
|
||||
</td>
|
||||
<td className="text-gray-600 dark:text-neutral-400">
|
||||
{app.lastEventAt
|
||||
? new Date(app.lastEventAt).toLocaleDateString()
|
||||
: "never"}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="align-top font-medium dark:text-white">
|
||||
Expires at
|
||||
</td>
|
||||
<td className="text-gray-600 dark:text-neutral-400">
|
||||
{app.expiresAt
|
||||
? new Date(app.expiresAt).toLocaleDateString()
|
||||
: "never"}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="py-4">
|
||||
<h3 className="text-xl font-headline dark:text-white">
|
||||
Permissions
|
||||
</h3>
|
||||
<ul className="mt-2 text-gray-600 dark:text-neutral-400">
|
||||
{app.requestMethods.map((method, index) => (
|
||||
<li key={index} className="mb-2 relative pl-6">
|
||||
<span className="absolute left-0 text-green-500">✓</span>
|
||||
{nip47MethodDescriptions[method as RequestMethodType]}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{app.maxAmount > 0 && (
|
||||
<div className="pl-6">
|
||||
<table className="text-gray-600 dark:text-neutral-400">
|
||||
<tr>
|
||||
<td className="font-medium">Budget</td>
|
||||
<td>
|
||||
{app.maxAmount} sats ({app.budgetUsage} sats used)
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="font-medium pr-3">Renews</td>
|
||||
<td>{app.budgetRenewal}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="pt-4">
|
||||
<h3 className="text-xl font-headline mb-2 dark:text-white">
|
||||
⚠️ Danger zone
|
||||
</h3>
|
||||
<p className="text-gray-600 dark:text-neutral-400 mb-4">
|
||||
This will revoke the permission and will no longer allow calls
|
||||
from this public key.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" onSubmit={handleDelete}>
|
||||
<button
|
||||
type="submit"
|
||||
className="inline-flex bg-white border border-red-400 cursor-pointer dark:bg-surface-02dp dark:hover:bg-surface-16dp duration-150 focus-visible:ring-2 focus-visible:ring-offset-2 focus:outline-none font-medium hover:bg-gray-50 items-center justify-center px-5 py-3 rounded-md shadow text-gray-700 dark:text-neutral-300 transition w-full sm:w-[250px] sm:mr-8 mt-8 sm:mt-0 order-last sm:order-first"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShowApp;
|
||||
2
frontend/src/swr.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export const swrFetcher = (...args: Parameters<typeof fetch>) =>
|
||||
fetch(...args).then((res) => res.json());
|
||||
110
frontend/src/types.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
export const NIP_47_PAY_INVOICE_METHOD = "pay_invoice";
|
||||
export const NIP_47_GET_BALANCE_METHOD = "get_balance";
|
||||
export const NIP_47_GET_INFO_METHOD = "get_info";
|
||||
export const NIP_47_MAKE_INVOICE_METHOD = "make_invoice";
|
||||
export const NIP_47_LOOKUP_INVOICE_METHOD = "lookup_invoice";
|
||||
export const NIP_47_LIST_TRANSACTIONS_METHOD = "list_transactions";
|
||||
|
||||
export type BackendType = "ALBY" | "LND";
|
||||
|
||||
export type RequestMethodType =
|
||||
| "pay_invoice"
|
||||
| "get_balance"
|
||||
| "get_info"
|
||||
| "make_invoice"
|
||||
| "lookup_invoice"
|
||||
| "list_transactions";
|
||||
|
||||
export type BudgetRenewalType = "daily" | "weekly" | "monthly" | "yearly" | "";
|
||||
|
||||
export type IconMap = {
|
||||
[key in RequestMethodType]: (
|
||||
props: React.SVGAttributes<SVGElement>
|
||||
) => JSX.Element;
|
||||
};
|
||||
|
||||
export const validBudgetRenewals: BudgetRenewalType[] = [
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
"",
|
||||
];
|
||||
|
||||
export const nip47MethodDescriptions: Record<RequestMethodType, string> = {
|
||||
[NIP_47_GET_BALANCE_METHOD]: "Read your balance",
|
||||
[NIP_47_GET_INFO_METHOD]: "Read your node info",
|
||||
[NIP_47_LIST_TRANSACTIONS_METHOD]: "Read incoming transaction history",
|
||||
[NIP_47_LOOKUP_INVOICE_METHOD]: "Lookup status of invoices",
|
||||
[NIP_47_MAKE_INVOICE_METHOD]: "Create invoices",
|
||||
[NIP_47_PAY_INVOICE_METHOD]: "Send payments",
|
||||
};
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
albyIdentifier: string;
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
email: string;
|
||||
expiry: string;
|
||||
lightningAddress: string;
|
||||
apps: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
export interface App {
|
||||
id: number;
|
||||
userId: number;
|
||||
user: User;
|
||||
name: string;
|
||||
description: string;
|
||||
nostrPubkey: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastEventAt?: string;
|
||||
expiresAt?: string;
|
||||
|
||||
requestMethods: string[];
|
||||
maxAmount: number;
|
||||
budgetUsage: number;
|
||||
budgetRenewal: string;
|
||||
}
|
||||
|
||||
export interface AppPermission {
|
||||
id: number;
|
||||
appId: number;
|
||||
app: App;
|
||||
requestMethod: RequestMethodType;
|
||||
maxAmount: number;
|
||||
budgetRenewal: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface NostrEvent {
|
||||
id: number;
|
||||
appId: number;
|
||||
app: App;
|
||||
nostrId: string;
|
||||
replyId: string;
|
||||
content: string;
|
||||
state: string;
|
||||
repliedAt: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface InfoResponse {
|
||||
backendType: BackendType;
|
||||
}
|
||||
|
||||
export interface CreateAppResponse {
|
||||
name: string;
|
||||
pairingUri: string;
|
||||
pairingPublicKey: string;
|
||||
pairingSecretKey: string;
|
||||
returnTo: string;
|
||||
}
|
||||
18
frontend/src/utils/fetch.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import toast from "../components/Toast";
|
||||
|
||||
export async function validateFetchResponse(response: Response) {
|
||||
if (!response.ok) {
|
||||
let reason = "unknown";
|
||||
try {
|
||||
reason = await response.text();
|
||||
} catch (error) {
|
||||
console.error("Failed to read response text", error);
|
||||
}
|
||||
throw new Error("Unexpected response: " + response.status + " " + reason);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFetchError(message: string, error: unknown) {
|
||||
console.error(message, error);
|
||||
toast.error(message + ": " + error);
|
||||
}
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
98
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
import defaultTheme from 'tailwindcss/defaultTheme';
|
||||
import forms from '@tailwindcss/forms';
|
||||
import aspectRatio from '@tailwindcss/aspect-ratio';
|
||||
import typography from '@tailwindcss/typography';
|
||||
|
||||
// NOTE: copied from:
|
||||
// https://github.com/getAlby/lightning-browser-extension/blob/1bab35feb58859d0786a9c2807a038395dd87fca/tailwind.config.js#L4-L21
|
||||
function lighten(color, percent) {
|
||||
var num = parseInt(color.replace("#", ""), 16),
|
||||
amt = Math.round(2.55 * percent),
|
||||
R = (num >> 16) + amt,
|
||||
B = ((num >> 8) & 0x00ff) + amt,
|
||||
G = (num & 0x0000ff) + amt;
|
||||
return (
|
||||
"#" +
|
||||
(
|
||||
0x1000000 +
|
||||
(R < 255 ? (R < 1 ? 0 : R) : 255) * 0x10000 +
|
||||
(B < 255 ? (B < 1 ? 0 : B) : 255) * 0x100 +
|
||||
(G < 255 ? (G < 1 ? 0 : G) : 255)
|
||||
)
|
||||
.toString(16)
|
||||
.slice(1)
|
||||
);
|
||||
}
|
||||
|
||||
const surfaceColor = "#121212";
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
mono: defaultTheme.fontFamily.mono,
|
||||
sans: ["Inter var", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
colors: {
|
||||
"alby-grey": "#272828",
|
||||
"alby-yellow": "#FFDE6E",
|
||||
"alby-yellow-light": "#FDF0D5",
|
||||
bitcoin: "#F7931A",
|
||||
"cold-grey": "#C5C7C8",
|
||||
light: "#F4F4F4",
|
||||
primary: "#F8C455",
|
||||
"warm-grey": "#D2D2D1",
|
||||
|
||||
// Material Design Surface Colors
|
||||
"surface-00dp": surfaceColor,
|
||||
"surface-01dp": lighten(surfaceColor, 5),
|
||||
"surface-02dp": lighten(surfaceColor, 7),
|
||||
"surface-03dp": lighten(surfaceColor, 8),
|
||||
"surface-04dp": lighten(surfaceColor, 9),
|
||||
"surface-06dp": lighten(surfaceColor, 11),
|
||||
"surface-08dp": lighten(surfaceColor, 12),
|
||||
"surface-12dp": lighten(surfaceColor, 14),
|
||||
"surface-16dp": lighten(surfaceColor, 15),
|
||||
"surface-24dp": lighten(surfaceColor, 16),
|
||||
},
|
||||
borderRadius: {
|
||||
"4xl": "1.25rem",
|
||||
30: "30px",
|
||||
},
|
||||
animation: {
|
||||
wiggle: "wiggle 0.3s ease-in-out",
|
||||
},
|
||||
keyframes: {
|
||||
wiggle: {
|
||||
"0%, 100%": { transform: "scale(1.0)" },
|
||||
"50%": { transform: "scale(1.03)" },
|
||||
},
|
||||
},
|
||||
blur: {
|
||||
xs: "2px",
|
||||
},
|
||||
},
|
||||
fontFamily: {
|
||||
headline: [
|
||||
'"Work Sans"',
|
||||
'"Inter var"',
|
||||
"Helvetica",
|
||||
"Arial",
|
||||
"sans-serif",
|
||||
],
|
||||
// required for Chrome, where e.g. ❤️ in Inter is displayed as a black heart :(
|
||||
emoji: ["sans-serif"],
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
forms,
|
||||
aspectRatio,
|
||||
typography,
|
||||
],
|
||||
}
|
||||
|
||||
25
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
13
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/alby": "http://localhost:8080",
|
||||
},
|
||||
},
|
||||
});
|
||||
1746
frontend/yarn.lock
Normal file
|
|
@ -208,7 +208,6 @@ type LookupInvoiceResponse struct {
|
|||
|
||||
type ErrorResponse struct {
|
||||
Error bool `json:"error"`
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
|
|
|
|||
39
models/api/api.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package api
|
||||
|
||||
import "time"
|
||||
|
||||
type App struct {
|
||||
// ID uint `json:"id"` // ID unused - pubkey is used as ID
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
NostrPubkey string `json:"nostrPubkey"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
|
||||
LastEventAt *time.Time `json:"lastEventAt"`
|
||||
ExpiresAt *time.Time `json:"expiresAt"`
|
||||
RequestMethods []string `json:"requestMethods"`
|
||||
MaxAmount int `json:"maxAmount"`
|
||||
BudgetUsage int64 `json:"budgetUsage"`
|
||||
BudgetRenewal string `json:"budgetRenewal"`
|
||||
}
|
||||
|
||||
type ListAppsResponse struct {
|
||||
Apps []App `json:"apps"`
|
||||
}
|
||||
|
||||
type CreateAppResponse struct {
|
||||
PairingUri string `json:"pairingUri"`
|
||||
PairingSecret string `json:"pairingSecretKey"`
|
||||
Pubkey string `json:"pairingPublicKey"`
|
||||
Name string `json:"name"`
|
||||
ReturnTo string `json:"returnTo"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type InfoResponse struct {
|
||||
BackendType string `json:"backendType"`
|
||||
}
|
||||
910
package-lock.json
generated
|
|
@ -1,11 +1,6 @@
|
|||
{
|
||||
"scripts": {
|
||||
"css": "tailwindcss -i ./views/application.css -o ./public/css/application.css --watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/aspect-ratio": "^0.4.2",
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"tailwindcss": "^3.2.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,43 +0,0 @@
|
|||
@font-face {
|
||||
font-family: 'Inter var';
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-style: normal;
|
||||
font-named-instance: 'Regular';
|
||||
src: url(/public/fonts/Inter-roman.var.woff2) format("woff2");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter var';
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
font-style: italic;
|
||||
font-named-instance: 'Italic';
|
||||
src: url(/public/fonts/Inter-italic.var.woff2) format("woff2");
|
||||
}
|
||||
|
||||
/* NOTE: source: https://gwfh.mranftl.com/fonts/work-sans?subsets=latin */
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Work Sans';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/public/fonts/work-sans-v18-latin-500.eot'); /* IE9 Compat Modes */
|
||||
src: url('/public/fonts/work-sans-v18-latin-500.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
url('/public/fonts/work-sans-v18-latin-500.woff2') format('woff2'), /* Super Modern Browsers */
|
||||
url('/public/fonts/work-sans-v18-latin-500.woff') format('woff'), /* Modern Browsers */
|
||||
url('/public/fonts/work-sans-v18-latin-500.ttf') format('truetype'), /* Safari, Android, iOS */
|
||||
url('/public/fonts/work-sans-v18-latin-500.svg#WorkSans') format('svg'); /* Legacy iOS */
|
||||
}
|
||||
@font-face {
|
||||
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
|
||||
font-family: 'Work Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/public/fonts/work-sans-v18-latin-600.eot'); /* IE9 Compat Modes */
|
||||
src: url('/public/fonts/work-sans-v18-latin-600.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
||||
url('/public/fonts/work-sans-v18-latin-600.woff2') format('woff2'), /* Super Modern Browsers */
|
||||
url('/public/fonts/work-sans-v18-latin-600.woff') format('woff'), /* Modern Browsers */
|
||||
url('/public/fonts/work-sans-v18-latin-600.ttf') format('truetype'), /* Safari, Android, iOS */
|
||||
url('/public/fonts/work-sans-v18-latin-600.svg#WorkSans') format('svg'); /* Legacy iOS */
|
||||
}
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.3814 5.35432C18.738 5.56495 18.8564 6.02484 18.6458 6.3815L11.5591 18.3815C11.4426 18.5787 11.2424 18.7119 11.0156 18.7431C10.7887 18.7743 10.56 18.7002 10.3946 18.5418L5.48126 13.8366C5.18211 13.5501 5.17184 13.0753 5.45833 12.7761C5.74482 12.477 6.21958 12.4667 6.51874 12.7532L10.7487 16.804L17.3542 5.61874C17.5648 5.26208 18.0247 5.14369 18.3814 5.35432Z" fill="black"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 532 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path
|
||||
d="M3.995 17.207V19.5a.5.5 0 00.5.5h2.298a.5.5 0 00.353-.146l9.448-9.448-3-3-9.452 9.448a.5.5 0 00-.147.353zM14.832 6.167l3 3 1.46-1.46a1 1 0 000-1.414l-1.585-1.586a1 1 0 00-1.414 0l-1.46 1.46z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 327 B |
|
|
@ -1,6 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M7.5 4A1.5 1.5 0 006 5.5v13A1.5 1.5 0 007.5 20h9a1.5 1.5 0 001.5-1.5v-13A1.5 1.5 0 0016.5 4h-9zm6.854 4.354a.5.5 0 00-.708-.708l-4 4a.5.5 0 00.708.708l4-4zM11.5 8.5a1 1 0 11-2 0 1 1 0 012 0zm2 4a1 1 0 100-2 1 1 0 000 2zm-5 2.5a.5.5 0 01.5-.5h6a.5.5 0 010 1H9a.5.5 0 01-.5-.5zm.5 1.5a.5.5 0 000 1h6a.5.5 0 000-1H9z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 494 B |
|
|
@ -1,5 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor" data-v-1286450c="">
|
||||
<path
|
||||
d="M18.496 10.709l-8.636 8.88c-.24.246-.638-.039-.482-.345l3.074-6.066a.3.3 0 00-.268-.436H5.718a.3.3 0 01-.214-.51l8.01-8.115c.232-.235.618.023.489.328L11.706 9.86a.3.3 0 00.28.417l6.291-.078a.3.3 0 01.22.509z"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 363 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1210" height="525" fill="none"><circle r="36.7" fill="#000" transform="matrix(-1 0 0 1 94.1 138.1)"/><path stroke="#000" stroke-width="18.3" d="m88 132.6 68.5 68.5"/><circle cx="393.6" cy="138.1" r="36.7" fill="#000"/><path stroke="#000" stroke-width="18.3" d="m400.4 132.6-68.5 68.5"/><path fill="#FFDF6F" fill-rule="evenodd" d="M104.5 357a48.5 48.5 0 0 1-27.4-52.1c16.6-92.5 85.5-161.9 168-161.9 82.7 0 151.8 69.7 168.1 162.6a48.5 48.5 0 0 1-27.6 52 327.6 327.6 0 0 1-139.9 31.2c-50.5 0-98.4-11.4-141.2-31.8Z" clip-rule="evenodd"/><path fill="#000" d="m413.2 305.6-9 1.6 9-1.6Zm-27.6 52-4-8.3 4 8.3Zm-299.4-51c16-89.5 82-154.4 159-154.4v-18.3c-88.2 0-160 73.8-177.1 169.4l18 3.2Zm159-154.4c77 0 143.2 65.3 159 155l18-3.2c-16.8-96-88.8-170.1-177-170.1v18.3Zm136.5 197.1a318.4 318.4 0 0 1-136 30.3v18.3c51.4 0 100.1-11.5 143.8-32l-7.8-16.6Zm-136 30.3c-49.1 0-95.6-11.1-137.2-30.9l-7.9 16.6c44 21 93.2 32.6 145.1 32.6v-18.3Zm158.5-72.4c3 17.3-6.2 34.5-22.5 42.1l7.8 16.6c23.4-11 37.3-36 32.7-62l-18 3.3Zm-336.1-4a57.6 57.6 0 0 0 32.5 62l7.9-16.5a39.3 39.3 0 0 1-22.3-42.2L68 303.3Z"/><path fill="#000" fill-rule="evenodd" d="M139.8 334c-15.9-6.5-25.3-23.4-19.8-39.7 17-50.2 66.6-86.5 125.1-86.5s108.1 36.3 125.1 86.5c5.6 16.3-3.9 33.2-19.8 39.7a278.6 278.6 0 0 1-105.3 20.5c-37.2 0-72.8-7.3-105.3-20.5Z" clip-rule="evenodd"/><ellipse cx="287.3" cy="286.1" fill="#fff" rx="30.6" ry="24.4"/><ellipse cx="199.8" cy="286.1" fill="#fff" rx="30.6" ry="24.4"/><path fill="#000" d="M623.9 134.4h58.2l81.3 215-57.3 5.8-15.6-46h-75.2l-15 42.8h-58l81.6-217.6Zm50 129L652 204.2l-20.8 59.2h42.5ZM837.5 352h-58.2V129.6l58.2-3.5V352Zm61.4 0h-29.4V129.6l58.2-3.5v69.4c3.6-1.3 7.4-2.2 11.2-2.9a93.2 93.2 0 0 1 52.8 7.4 63.8 63.8 0 0 1 28.2 26.6c7 12 10.5 27.2 10.5 45.7a92 92 0 0 1-10.5 46.1 66 66 0 0 1-28.8 27.8 93.4 93.4 0 0 1-43 9.3 65.3 65.3 0 0 1-39.3-13.4L899 352Zm42.9-115.8a27 27 0 0 0-14.1 4.8v65.2c5.8 3 11.3 4.5 16.6 4.5a22 22 0 0 0 18.3-9.9 46 46 0 0 0 7.7-28.2c0-12-2.5-21-7.4-27.2-4.9-6.1-12-9.2-21.1-9.2ZM1088 352.6l1.3-3.5-55.7-142.7 53.8-15.4 31.7 93.5 29.7-87.7h58l-68.2 173.4c-3.2 8.4-8.4 16-15.7 23-7.3 7.1-15.7 13-25.3 18a128 128 0 0 1-29.7 10.9l-19.6-45.1 16-7c5.8-2.6 11-5.4 15.4-8.4 4.5-3 7.3-6 8.3-9Z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.2 KiB |
|
|
@ -1,6 +0,0 @@
|
|||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
<path d="M19.2854 12.0002L11.2727 12.0002" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
|
||||
<path d="M16.5101 15.6364L19.9999 12L16.5101 8.36363" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
|
||||
<path d="M13.4545 7V4H4V20H13.4545V17" stroke="#f87171" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 496 B |
|
|
@ -1 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path fill-rule="evenodd" d="M12 3.25a.75.75 0 01.75.75v7.25H20a.75.75 0 010 1.5h-7.25V20a.75.75 0 01-1.5 0v-7.25H4a.75.75 0 010-1.5h7.25V4a.75.75 0 01.75-.75z" clip-rule="evenodd"></path></svg>
|
||||
|
Before Width: | Height: | Size: 267 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10.5 5.5a5 5 0 100 10 5 5 0 000-10zm-6.5 5a6.5 6.5 0 1113 0 6.5 6.5 0 01-13 0z"
|
||||
clip-rule="evenodd"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M14.47 14.47a.75.75 0 011.06 0l4 4a.75.75 0 11-1.06 1.06l-4-4a.75.75 0 010-1.06z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 409 B |
|
|
@ -1,15 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<circle cx="5.5" cy="7.5" r="1.5"></circle>
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 6.5a.5.5 0 01.5-.5h11a.5.5 0 010 1h-11a.5.5 0 01-.5-.5zM8 8.5a.5.5 0 01.5-.5h6a.5.5 0 010 1h-6a.5.5 0 01-.5-.5z"
|
||||
clip-rule="evenodd"></path>
|
||||
<circle cx="5.5" cy="12" r="1.5"></circle>
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 11a.5.5 0 01.5-.5h8a.5.5 0 010 1h-8A.5.5 0 018 11zM8 13a.5.5 0 01.5-.5h7a.5.5 0 010 1h-7A.5.5 0 018 13z"
|
||||
clip-rule="evenodd"></path>
|
||||
<circle cx="5.5" cy="16.5" r="1.5"></circle>
|
||||
<path fill-rule="evenodd"
|
||||
d="M8 15.5a.5.5 0 01.5-.5H18a.5.5 0 010 1H8.5a.5.5 0 01-.5-.5zM8 17.5a.5.5 0 01.5-.5h4a.5.5 0 010 1h-4a.5.5 0 01-.5-.5z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 793 B |
|
|
@ -1,9 +0,0 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"
|
||||
fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M12 8a2 2 0 012-2h4a2 2 0 012 2v8a2 2 0 01-2 2h-4a2 2 0 01-2-2V8zm2-1a1 1 0 00-1 1v8a1 1 0 001 1h4a1 1 0 001-1V8a1 1 0 00-1-1h-4z"
|
||||
clip-rule="evenodd"></path>
|
||||
<path fill-rule="evenodd"
|
||||
d="M5.5 6A1.5 1.5 0 004 7.5v9A1.5 1.5 0 005.5 18h10a1.5 1.5 0 001.5-1.5v-9A1.5 1.5 0 0015.5 6h-10zm2 7.5a1.5 1.5 0 100-3 1.5 1.5 0 000 3z"
|
||||
clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 513 B |
|
|
@ -1,82 +0,0 @@
|
|||
/*
|
||||
jquery-qrcode v0.14.0 - https://larsjung.de/jquery-qrcode/ */
|
||||
'use strict'; let G = null; class H { } H.render = function(w, B) { G(w, B) }; self.QrCreator = H;
|
||||
(function(w) {
|
||||
function B(t, c, a, e) { var b = {}, h = w(a, c); h.u(t); h.J(); e = e || 0; var r = h.h(), d = h.h() + 2 * e; b.text = t; b.level = c; b.version = a; b.O = d; b.a = function(b, a) { b -= e; a -= e; return 0 > b || b >= r || 0 > a || a >= r ? !1 : h.a(b, a) }; return b } function C(t, c, a, e, b, h, r, d, g, x) { function u(b, a, f, c, d, r, g) { b ? (t.lineTo(a + r, f + g), t.arcTo(a, f, c, d, h)) : t.lineTo(a, f) } r ? t.moveTo(c + h, a) : t.moveTo(c, a); u(d, e, a, e, b, -h, 0); u(g, e, b, c, b, 0, -h); u(x, c, b, c, a, h, 0); u(r, c, a, e, a, 0, h) } function z(t, c, a, e, b, h, r, d, g, x) {
|
||||
function u(b, a, c, d) {
|
||||
t.moveTo(b + c, a); t.lineTo(b,
|
||||
a); t.lineTo(b, a + d); t.arcTo(b, a, b + c, a, h)
|
||||
} r && u(c, a, h, h); d && u(e, a, -h, h); g && u(e, b, -h, -h); x && u(c, b, h, -h)
|
||||
} function A(t, c) { var a = c.fill; if ("string" === typeof a) t.fillStyle = a; else { var e = a.type, b = a.colorStops; a = a.position.map((b) => Math.round(b * c.size)); if ("linear-gradient" === e) var h = t.createLinearGradient.apply(t, a); else if ("radial-gradient" === e) h = t.createRadialGradient.apply(t, a); else throw Error("Unsupported fill"); b.forEach(([b, a]) => { h.addColorStop(b, a) }); t.fillStyle = h } } function y(t, c) {
|
||||
a: {
|
||||
var a = c.text, e =
|
||||
c.v, b = c.N, h = c.K, r = c.P; b = Math.max(1, b || 1); for (h = Math.min(40, h || 40); b <= h; b += 1)try { var d = B(a, e, b, r); break a } catch (J) { } d = void 0
|
||||
} if (!d) return null; a = t.getContext("2d"); c.background && (a.fillStyle = c.background, a.fillRect(c.left, c.top, c.size, c.size)); e = d.O; h = c.size / e; a.beginPath(); for (r = 0; r < e; r += 1)for (b = 0; b < e; b += 1) {
|
||||
var g = a, x = c.left + b * h, u = c.top + r * h, p = r, q = b, f = d.a, k = x + h, m = u + h, D = p - 1, E = p + 1, n = q - 1, l = q + 1, y = Math.floor(Math.min(.5, Math.max(0, c.R)) * h), v = f(p, q), I = f(D, n), w = f(D, q); D = f(D, l); var F = f(p, l); l = f(E, l); q = f(E,
|
||||
q); E = f(E, n); p = f(p, n); x = Math.round(x); u = Math.round(u); k = Math.round(k); m = Math.round(m); v ? C(g, x, u, k, m, y, !w && !p, !w && !F, !q && !F, !q && !p) : z(g, x, u, k, m, y, w && p && I, w && F && D, q && F && l, q && p && E)
|
||||
} A(a, c); a.fill(); return t
|
||||
} var v = { minVersion: 1, maxVersion: 40, ecLevel: "L", left: 0, top: 0, size: 200, fill: "#000", background: null, text: "no text", radius: .5, quiet: 0 }; G = function(t, c) {
|
||||
var a = {}; Object.assign(a, v, t); a.N = a.minVersion; a.K = a.maxVersion; a.v = a.ecLevel; a.left = a.left; a.top = a.top; a.size = a.size; a.fill = a.fill; a.background = a.background;
|
||||
a.text = a.text; a.R = a.radius; a.P = a.quiet; if (c instanceof HTMLCanvasElement) { if (c.width !== a.size || c.height !== a.size) c.width = a.size, c.height = a.size; c.getContext("2d").clearRect(0, 0, c.width, c.height); y(c, a) } else t = document.createElement("canvas"), t.width = a.size, t.height = a.size, a = y(t, a), c.appendChild(a)
|
||||
}
|
||||
})(function() {
|
||||
function w(c) { var a = C.s(c); return { S: function() { return 4 }, b: function() { return a.length }, write: function(c) { for (var b = 0; b < a.length; b += 1)c.put(a[b], 8) } } } function B() {
|
||||
var c = [], a = 0, e = {
|
||||
B: function() { return c },
|
||||
c: function(b) { return 1 == (c[Math.floor(b / 8)] >>> 7 - b % 8 & 1) }, put: function(b, h) { for (var a = 0; a < h; a += 1)e.m(1 == (b >>> h - a - 1 & 1)) }, f: function() { return a }, m: function(b) { var h = Math.floor(a / 8); c.length <= h && c.push(0); b && (c[h] |= 128 >>> a % 8); a += 1 }
|
||||
}; return e
|
||||
} function C(c, a) {
|
||||
function e(b, h) { for (var a = -1; 7 >= a; a += 1)if (!(-1 >= b + a || d <= b + a)) for (var c = -1; 7 >= c; c += 1)-1 >= h + c || d <= h + c || (r[b + a][h + c] = 0 <= a && 6 >= a && (0 == c || 6 == c) || 0 <= c && 6 >= c && (0 == a || 6 == a) || 2 <= a && 4 >= a && 2 <= c && 4 >= c ? !0 : !1) } function b(b, a) {
|
||||
for (var f = d = 4 * c + 17, k = Array(f), m = 0; m <
|
||||
f; m += 1) { k[m] = Array(f); for (var p = 0; p < f; p += 1)k[m][p] = null } r = k; e(0, 0); e(d - 7, 0); e(0, d - 7); f = y.G(c); for (k = 0; k < f.length; k += 1)for (m = 0; m < f.length; m += 1) { p = f[k]; var q = f[m]; if (null == r[p][q]) for (var n = -2; 2 >= n; n += 1)for (var l = -2; 2 >= l; l += 1)r[p + n][q + l] = -2 == n || 2 == n || -2 == l || 2 == l || 0 == n && 0 == l } for (f = 8; f < d - 8; f += 1)null == r[f][6] && (r[f][6] = 0 == f % 2); for (f = 8; f < d - 8; f += 1)null == r[6][f] && (r[6][f] = 0 == f % 2); f = y.w(h << 3 | a); for (k = 0; 15 > k; k += 1)m = !b && 1 == (f >> k & 1), r[6 > k ? k : 8 > k ? k + 1 : d - 15 + k][8] = m, r[8][8 > k ? d - k - 1 : 9 > k ? 15 - k : 14 - k] = m; r[d - 8][8] = !b; if (7 <=
|
||||
c) { f = y.A(c); for (k = 0; 18 > k; k += 1)m = !b && 1 == (f >> k & 1), r[Math.floor(k / 3)][k % 3 + d - 8 - 3] = m; for (k = 0; 18 > k; k += 1)m = !b && 1 == (f >> k & 1), r[k % 3 + d - 8 - 3][Math.floor(k / 3)] = m } if (null == g) {
|
||||
b = t.I(c, h); f = B(); for (k = 0; k < x.length; k += 1)m = x[k], f.put(4, 4), f.put(m.b(), y.f(4, c)), m.write(f); for (k = m = 0; k < b.length; k += 1)m += b[k].j; if (f.f() > 8 * m) throw Error("code length overflow. (" + f.f() + ">" + 8 * m + ")"); for (f.f() + 4 <= 8 * m && f.put(0, 4); 0 != f.f() % 8;)f.m(!1); for (; !(f.f() >= 8 * m);) { f.put(236, 8); if (f.f() >= 8 * m) break; f.put(17, 8) } var u = 0; m = k = 0; p = Array(b.length);
|
||||
q = Array(b.length); for (n = 0; n < b.length; n += 1) { var v = b[n].j, w = b[n].o - v; k = Math.max(k, v); m = Math.max(m, w); p[n] = Array(v); for (l = 0; l < p[n].length; l += 1)p[n][l] = 255 & f.B()[l + u]; u += v; l = y.C(w); v = z(p[n], l.b() - 1).l(l); q[n] = Array(l.b() - 1); for (l = 0; l < q[n].length; l += 1)w = l + v.b() - q[n].length, q[n][l] = 0 <= w ? v.c(w) : 0 } for (l = f = 0; l < b.length; l += 1)f += b[l].o; f = Array(f); for (l = u = 0; l < k; l += 1)for (n = 0; n < b.length; n += 1)l < p[n].length && (f[u] = p[n][l], u += 1); for (l = 0; l < m; l += 1)for (n = 0; n < b.length; n += 1)l < q[n].length && (f[u] = q[n][l], u += 1); g = f
|
||||
} b = g; f =
|
||||
-1; k = d - 1; m = 7; p = 0; a = y.F(a); for (q = d - 1; 0 < q; q -= 2)for (6 == q && --q; ;) { for (n = 0; 2 > n; n += 1)null == r[k][q - n] && (l = !1, p < b.length && (l = 1 == (b[p] >>> m & 1)), a(k, q - n) && (l = !l), r[k][q - n] = l, --m, -1 == m && (p += 1, m = 7)); k += f; if (0 > k || d <= k) { k -= f; f = -f; break } }
|
||||
} var h = A[a], r = null, d = 0, g = null, x = [], u = { u: function(b) { b = w(b); x.push(b); g = null }, a: function(b, a) { if (0 > b || d <= b || 0 > a || d <= a) throw Error(b + "," + a); return r[b][a] }, h: function() { return d }, J: function() { for (var a = 0, h = 0, c = 0; 8 > c; c += 1) { b(!0, c); var d = y.D(u); if (0 == c || a > d) a = d, h = c } b(!1, h) } }; return u
|
||||
}
|
||||
function z(c, a) {
|
||||
if ("undefined" == typeof c.length) throw Error(c.length + "/" + a); var e = function() { for (var b = 0; b < c.length && 0 == c[b];)b += 1; for (var r = Array(c.length - b + a), d = 0; d < c.length - b; d += 1)r[d] = c[d + b]; return r }(), b = {
|
||||
c: function(b) { return e[b] }, b: function() { return e.length }, multiply: function(a) { for (var h = Array(b.b() + a.b() - 1), c = 0; c < b.b(); c += 1)for (var g = 0; g < a.b(); g += 1)h[c + g] ^= v.i(v.g(b.c(c)) + v.g(a.c(g))); return z(h, 0) }, l: function(a) {
|
||||
if (0 > b.b() - a.b()) return b; for (var c = v.g(b.c(0)) - v.g(a.c(0)), h = Array(b.b()),
|
||||
g = 0; g < b.b(); g += 1)h[g] = b.c(g); for (g = 0; g < a.b(); g += 1)h[g] ^= v.i(v.g(a.c(g)) + c); return z(h, 0).l(a)
|
||||
}
|
||||
}; return b
|
||||
} C.s = function(c) { for (var a = [], e = 0; e < c.length; e++) { var b = c.charCodeAt(e); 128 > b ? a.push(b) : 2048 > b ? a.push(192 | b >> 6, 128 | b & 63) : 55296 > b || 57344 <= b ? a.push(224 | b >> 12, 128 | b >> 6 & 63, 128 | b & 63) : (e++, b = 65536 + ((b & 1023) << 10 | c.charCodeAt(e) & 1023), a.push(240 | b >> 18, 128 | b >> 12 & 63, 128 | b >> 6 & 63, 128 | b & 63)) } return a }; var A = { L: 1, M: 0, Q: 3, H: 2 }, y = function() {
|
||||
function c(b) { for (var a = 0; 0 != b;)a += 1, b >>>= 1; return a } var a = [[], [6, 18],
|
||||
[6, 22], [6, 26], [6, 30], [6, 34], [6, 22, 38], [6, 24, 42], [6, 26, 46], [6, 28, 50], [6, 30, 54], [6, 32, 58], [6, 34, 62], [6, 26, 46, 66], [6, 26, 48, 70], [6, 26, 50, 74], [6, 30, 54, 78], [6, 30, 56, 82], [6, 30, 58, 86], [6, 34, 62, 90], [6, 28, 50, 72, 94], [6, 26, 50, 74, 98], [6, 30, 54, 78, 102], [6, 28, 54, 80, 106], [6, 32, 58, 84, 110], [6, 30, 58, 86, 114], [6, 34, 62, 90, 118], [6, 26, 50, 74, 98, 122], [6, 30, 54, 78, 102, 126], [6, 26, 52, 78, 104, 130], [6, 30, 56, 82, 108, 134], [6, 34, 60, 86, 112, 138], [6, 30, 58, 86, 114, 142], [6, 34, 62, 90, 118, 146], [6, 30, 54, 78, 102, 126, 150], [6, 24, 50, 76, 102, 128, 154],
|
||||
[6, 28, 54, 80, 106, 132, 158], [6, 32, 58, 84, 110, 136, 162], [6, 26, 54, 82, 110, 138, 166], [6, 30, 58, 86, 114, 142, 170]], e = {
|
||||
w: function(b) { for (var a = b << 10; 0 <= c(a) - c(1335);)a ^= 1335 << c(a) - c(1335); return (b << 10 | a) ^ 21522 }, A: function(b) { for (var a = b << 12; 0 <= c(a) - c(7973);)a ^= 7973 << c(a) - c(7973); return b << 12 | a }, G: function(b) { return a[b - 1] }, F: function(b) {
|
||||
switch (b) {
|
||||
case 0: return function(b, a) { return 0 == (b + a) % 2 }; case 1: return function(b) { return 0 == b % 2 }; case 2: return function(b, a) { return 0 == a % 3 }; case 3: return function(b, a) {
|
||||
return 0 ==
|
||||
(b + a) % 3
|
||||
}; case 4: return function(b, a) { return 0 == (Math.floor(b / 2) + Math.floor(a / 3)) % 2 }; case 5: return function(b, a) { return 0 == b * a % 2 + b * a % 3 }; case 6: return function(b, a) { return 0 == (b * a % 2 + b * a % 3) % 2 }; case 7: return function(b, a) { return 0 == (b * a % 3 + (b + a) % 2) % 2 }; default: throw Error("bad maskPattern:" + b);
|
||||
}
|
||||
}, C: function(b) { for (var a = z([1], 0), c = 0; c < b; c += 1)a = a.multiply(z([1, v.i(c)], 0)); return a }, f: function(b, a) { if (4 != b || 1 > a || 40 < a) throw Error("mode: " + b + "; type: " + a); return 10 > a ? 8 : 16 }, D: function(b) {
|
||||
for (var a = b.h(), c = 0,
|
||||
d = 0; d < a; d += 1)for (var g = 0; g < a; g += 1) { for (var e = 0, t = b.a(d, g), p = -1; 1 >= p; p += 1)if (!(0 > d + p || a <= d + p)) for (var q = -1; 1 >= q; q += 1)0 > g + q || a <= g + q || (0 != p || 0 != q) && t == b.a(d + p, g + q) && (e += 1); 5 < e && (c += 3 + e - 5) } for (d = 0; d < a - 1; d += 1)for (g = 0; g < a - 1; g += 1)if (e = 0, b.a(d, g) && (e += 1), b.a(d + 1, g) && (e += 1), b.a(d, g + 1) && (e += 1), b.a(d + 1, g + 1) && (e += 1), 0 == e || 4 == e) c += 3; for (d = 0; d < a; d += 1)for (g = 0; g < a - 6; g += 1)b.a(d, g) && !b.a(d, g + 1) && b.a(d, g + 2) && b.a(d, g + 3) && b.a(d, g + 4) && !b.a(d, g + 5) && b.a(d, g + 6) && (c += 40); for (g = 0; g < a; g += 1)for (d = 0; d < a - 6; d += 1)b.a(d, g) && !b.a(d +
|
||||
1, g) && b.a(d + 2, g) && b.a(d + 3, g) && b.a(d + 4, g) && !b.a(d + 5, g) && b.a(d + 6, g) && (c += 40); for (g = e = 0; g < a; g += 1)for (d = 0; d < a; d += 1)b.a(d, g) && (e += 1); return c += Math.abs(100 * e / a / a - 50) / 5 * 10
|
||||
}
|
||||
}; return e
|
||||
}(), v = function() { for (var c = Array(256), a = Array(256), e = 0; 8 > e; e += 1)c[e] = 1 << e; for (e = 8; 256 > e; e += 1)c[e] = c[e - 4] ^ c[e - 5] ^ c[e - 6] ^ c[e - 8]; for (e = 0; 255 > e; e += 1)a[c[e]] = e; return { g: function(b) { if (1 > b) throw Error("glog(" + b + ")"); return a[b] }, i: function(b) { for (; 0 > b;)b += 255; for (; 256 <= b;)b -= 255; return c[b] } } }(), t = function() {
|
||||
function c(b, c) {
|
||||
switch (c) {
|
||||
case A.L: return a[4 *
|
||||
(b - 1)]; case A.M: return a[4 * (b - 1) + 1]; case A.Q: return a[4 * (b - 1) + 2]; case A.H: return a[4 * (b - 1) + 3]
|
||||
}
|
||||
} var a = [[1, 26, 19], [1, 26, 16], [1, 26, 13], [1, 26, 9], [1, 44, 34], [1, 44, 28], [1, 44, 22], [1, 44, 16], [1, 70, 55], [1, 70, 44], [2, 35, 17], [2, 35, 13], [1, 100, 80], [2, 50, 32], [2, 50, 24], [4, 25, 9], [1, 134, 108], [2, 67, 43], [2, 33, 15, 2, 34, 16], [2, 33, 11, 2, 34, 12], [2, 86, 68], [4, 43, 27], [4, 43, 19], [4, 43, 15], [2, 98, 78], [4, 49, 31], [2, 32, 14, 4, 33, 15], [4, 39, 13, 1, 40, 14], [2, 121, 97], [2, 60, 38, 2, 61, 39], [4, 40, 18, 2, 41, 19], [4, 40, 14, 2, 41, 15], [2, 146, 116], [3, 58, 36,
|
||||
2, 59, 37], [4, 36, 16, 4, 37, 17], [4, 36, 12, 4, 37, 13], [2, 86, 68, 2, 87, 69], [4, 69, 43, 1, 70, 44], [6, 43, 19, 2, 44, 20], [6, 43, 15, 2, 44, 16], [4, 101, 81], [1, 80, 50, 4, 81, 51], [4, 50, 22, 4, 51, 23], [3, 36, 12, 8, 37, 13], [2, 116, 92, 2, 117, 93], [6, 58, 36, 2, 59, 37], [4, 46, 20, 6, 47, 21], [7, 42, 14, 4, 43, 15], [4, 133, 107], [8, 59, 37, 1, 60, 38], [8, 44, 20, 4, 45, 21], [12, 33, 11, 4, 34, 12], [3, 145, 115, 1, 146, 116], [4, 64, 40, 5, 65, 41], [11, 36, 16, 5, 37, 17], [11, 36, 12, 5, 37, 13], [5, 109, 87, 1, 110, 88], [5, 65, 41, 5, 66, 42], [5, 54, 24, 7, 55, 25], [11, 36, 12, 7, 37, 13], [5, 122, 98, 1, 123, 99], [7, 73,
|
||||
45, 3, 74, 46], [15, 43, 19, 2, 44, 20], [3, 45, 15, 13, 46, 16], [1, 135, 107, 5, 136, 108], [10, 74, 46, 1, 75, 47], [1, 50, 22, 15, 51, 23], [2, 42, 14, 17, 43, 15], [5, 150, 120, 1, 151, 121], [9, 69, 43, 4, 70, 44], [17, 50, 22, 1, 51, 23], [2, 42, 14, 19, 43, 15], [3, 141, 113, 4, 142, 114], [3, 70, 44, 11, 71, 45], [17, 47, 21, 4, 48, 22], [9, 39, 13, 16, 40, 14], [3, 135, 107, 5, 136, 108], [3, 67, 41, 13, 68, 42], [15, 54, 24, 5, 55, 25], [15, 43, 15, 10, 44, 16], [4, 144, 116, 4, 145, 117], [17, 68, 42], [17, 50, 22, 6, 51, 23], [19, 46, 16, 6, 47, 17], [2, 139, 111, 7, 140, 112], [17, 74, 46], [7, 54, 24, 16, 55, 25], [34, 37, 13], [4,
|
||||
151, 121, 5, 152, 122], [4, 75, 47, 14, 76, 48], [11, 54, 24, 14, 55, 25], [16, 45, 15, 14, 46, 16], [6, 147, 117, 4, 148, 118], [6, 73, 45, 14, 74, 46], [11, 54, 24, 16, 55, 25], [30, 46, 16, 2, 47, 17], [8, 132, 106, 4, 133, 107], [8, 75, 47, 13, 76, 48], [7, 54, 24, 22, 55, 25], [22, 45, 15, 13, 46, 16], [10, 142, 114, 2, 143, 115], [19, 74, 46, 4, 75, 47], [28, 50, 22, 6, 51, 23], [33, 46, 16, 4, 47, 17], [8, 152, 122, 4, 153, 123], [22, 73, 45, 3, 74, 46], [8, 53, 23, 26, 54, 24], [12, 45, 15, 28, 46, 16], [3, 147, 117, 10, 148, 118], [3, 73, 45, 23, 74, 46], [4, 54, 24, 31, 55, 25], [11, 45, 15, 31, 46, 16], [7, 146, 116, 7, 147, 117],
|
||||
[21, 73, 45, 7, 74, 46], [1, 53, 23, 37, 54, 24], [19, 45, 15, 26, 46, 16], [5, 145, 115, 10, 146, 116], [19, 75, 47, 10, 76, 48], [15, 54, 24, 25, 55, 25], [23, 45, 15, 25, 46, 16], [13, 145, 115, 3, 146, 116], [2, 74, 46, 29, 75, 47], [42, 54, 24, 1, 55, 25], [23, 45, 15, 28, 46, 16], [17, 145, 115], [10, 74, 46, 23, 75, 47], [10, 54, 24, 35, 55, 25], [19, 45, 15, 35, 46, 16], [17, 145, 115, 1, 146, 116], [14, 74, 46, 21, 75, 47], [29, 54, 24, 19, 55, 25], [11, 45, 15, 46, 46, 16], [13, 145, 115, 6, 146, 116], [14, 74, 46, 23, 75, 47], [44, 54, 24, 7, 55, 25], [59, 46, 16, 1, 47, 17], [12, 151, 121, 7, 152, 122], [12, 75, 47, 26, 76, 48],
|
||||
[39, 54, 24, 14, 55, 25], [22, 45, 15, 41, 46, 16], [6, 151, 121, 14, 152, 122], [6, 75, 47, 34, 76, 48], [46, 54, 24, 10, 55, 25], [2, 45, 15, 64, 46, 16], [17, 152, 122, 4, 153, 123], [29, 74, 46, 14, 75, 47], [49, 54, 24, 10, 55, 25], [24, 45, 15, 46, 46, 16], [4, 152, 122, 18, 153, 123], [13, 74, 46, 32, 75, 47], [48, 54, 24, 14, 55, 25], [42, 45, 15, 32, 46, 16], [20, 147, 117, 4, 148, 118], [40, 75, 47, 7, 76, 48], [43, 54, 24, 22, 55, 25], [10, 45, 15, 67, 46, 16], [19, 148, 118, 6, 149, 119], [18, 75, 47, 31, 76, 48], [34, 54, 24, 34, 55, 25], [20, 45, 15, 61, 46, 16]], e = {
|
||||
I: function(b, a) {
|
||||
var e = c(b, a); if ("undefined" ==
|
||||
typeof e) throw Error("bad rs block @ typeNumber:" + b + "/errorCorrectLevel:" + a); b = e.length / 3; a = []; for (var d = 0; d < b; d += 1)for (var g = e[3 * d], h = e[3 * d + 1], t = e[3 * d + 2], p = 0; p < g; p += 1) { var q = t, f = {}; f.o = h; f.j = q; a.push(f) } return a
|
||||
}
|
||||
}; return e
|
||||
}(); return C
|
||||
}());
|
||||
//# sourceMappingURL=qr-creator.min.js.map
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
{{define "body"}}
|
||||
|
||||
<div
|
||||
class="bg-white rounded-md shadow px-4 lg:p-8 dark:bg-surface-02dp"
|
||||
>
|
||||
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">404 Page Not Found</h3>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
The page you are looking for does not exist.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||
133
views/about.html
|
|
@ -1,133 +0,0 @@
|
|||
{{define "body"}}
|
||||
|
||||
<div
|
||||
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
|
||||
>
|
||||
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">tl;dr</h3>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect (NWC) is a way for applications like Nostr clients to access a remote Lightning wallet through a <a href="https://github.com/getAlby/nips/blob/master/47.md" class="dark:text-gray-300 underline">standardized protocol</a>.
|
||||
For instance, you can link your <a href="https://getalby.com" class="dark:text-gray-300 underline">Alby account</a> with any other application using this feature.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
|
||||
>
|
||||
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">How to connect your app?</h3>
|
||||
<ol class="list-decimal list-inside leading-relaxed dark:text-neutral-400">
|
||||
<li class="mb-2">
|
||||
<strong class="dark:text-white">Create a new app connection</strong>
|
||||
<p class="ml-6">
|
||||
Enable the application by creating a permissioned connection to your wallet. This access
|
||||
can be revoked at any time.
|
||||
</p>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong class="dark:text-white">Connect your app</strong>
|
||||
<p class="ml-6">
|
||||
Connect your application by adding the connection secret. Either by
|
||||
scanning the connect QR code or clicking a link.
|
||||
</p>
|
||||
</li>
|
||||
<li class="mb-2">
|
||||
<strong class="dark:text-white">Done</strong>
|
||||
<p class="ml-6">
|
||||
Zapp and Boost! Your lightning wallet is available where you need it.
|
||||
<br />
|
||||
And if you ever want to disable it again, simply disconnect the connection again.
|
||||
</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-md shadow p-4 lg:p-8 mb-8 dark:bg-surface-02dp"
|
||||
>
|
||||
|
||||
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">FAQs</h3>
|
||||
|
||||
<h4 class="font-bold text-l font-headline mt-4 dark:text-white">How does it work?</h4>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect uses the existing Nostr infrastructure to allow
|
||||
applications to communicate with lightning wallets. This makes it easy for
|
||||
developers to integrate and gives the users full flexibility on choice.
|
||||
<br />
|
||||
To learn more about the details have a look at
|
||||
<a
|
||||
href="https://github.com/getAlby/nips/blob/7-wallet-connect-patch/47.md"
|
||||
class="dark:text-gray-300 underline"
|
||||
>the specs (NIP47)</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Which apps support it?</h4>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
<a href="https://linktr.ee/amethyst.social" class="dark:text-gray-300 underline">Amethyst</a>
|
||||
is the first Nostr Client to fully support Nostr Wallet Connect. More are
|
||||
working on support for it and are following shortly.
|
||||
</p>
|
||||
|
||||
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Is it open source?</h4>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
Of course! Nostr Wallet Connect is an open standard and our implementation
|
||||
is
|
||||
<a href="https://github.com/getAlby/nostr-wallet-connect/" class="dark:text-gray-300 underline"
|
||||
>open source on GitHub</a
|
||||
>
|
||||
</p>
|
||||
|
||||
<h4 class="font-bold text-l font-headline mt-6 dark:text-white">Can I run my own?</h4>
|
||||
<p class="leading-relaxed dark:text-neutral-400">
|
||||
Yes, Nostr Wallet Connect is for every wallet and can easily be
|
||||
self-hosted.<br />
|
||||
We are looking for contributors to add support for more wallet backends. If
|
||||
you're interested please contact us
|
||||
<a href="https://github.com/getAlby/nostr-wallet-connect/" class="dark:text-gray-300 underline"
|
||||
>on GitHub</a
|
||||
>
|
||||
or
|
||||
<a href="mailto:hello@getalby.com" class="dark:text-gray-300 underline">hello@getalby.com</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="bg-white rounded-md shadow p-4 lg:p-8 dark:bg-surface-02dp"
|
||||
>
|
||||
<h3 class="font-bold text-2xl font-headline mb-4 dark:text-white">For developers</h3>
|
||||
<p class="mb-1 leading-relaxed dark:text-neutral-400">
|
||||
Nostr Wallet Connect is an open protocol enabling applications to interact
|
||||
with bitcoin lightning wallets. It allows users to connect their existing
|
||||
wallets to your application allowing you to easily integrate bitcoin
|
||||
lightning functionality but still focus on the core functionality of your
|
||||
app.
|
||||
</p>
|
||||
|
||||
<ul class="list-inside list-disc mb-8 dark:text-neutral-400">
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/getAlby/nips/blob/master/47.md"
|
||||
class="dark:text-gray-300 underline"
|
||||
>Read the specs (NIP47)</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://github.com/getAlby/alby-js-sdk#nostr-wallet-connect-documentation"
|
||||
class="dark:text-gray-300 underline"
|
||||
>Look at the JavaScript SDK</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="sm:text-2xl font-bold font-headline mb-4 text-center dark:text-white">
|
||||
Happy zapping!
|
||||
<img
|
||||
src="/public/images/nostr-bitcoin.png"
|
||||
alt="Bitcoin on Nostr"
|
||||
class="w-80 mx-auto"
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{{end}}
|
||||