Merge branch 'vue-rewrite' into develop
This commit is contained in:
commit
b39878c495
@ -47,7 +47,8 @@ CREATE TABLE orders(
|
||||
side ORDER_SIDE NOT NULL,
|
||||
quantity BIGINT NOT NULL,
|
||||
price BIGINT NOT NULL,
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id)
|
||||
invoice_id UUID NOT NULL REFERENCES invoices(id),
|
||||
order_id UUID REFERENCES orders(id)
|
||||
);
|
||||
ALTER TABLE orders ADD CONSTRAINT order_price CHECK(price > 0 AND price < 100);
|
||||
ALTER TABLE orders ADD CONSTRAINT order_quantity CHECK(quantity > 0);
|
||||
|
@ -1,12 +1,13 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *DB) CreateInvoice(invoice *Invoice) error {
|
||||
if err := db.QueryRow(""+
|
||||
func (db *DB) CreateInvoice(tx *sql.Tx, ctx context.Context, invoice *Invoice) error {
|
||||
if err := tx.QueryRowContext(ctx, ""+
|
||||
"INSERT INTO invoices(pubkey, msats, preimage, hash, bolt11, created_at, expires_at, description) "+
|
||||
"VALUES($1, $2, $3, $4, $5, $6, $7, $8) "+
|
||||
"RETURNING id",
|
||||
@ -70,6 +71,33 @@ func (db *DB) FetchInvoices(where *FetchInvoicesWhere, invoices *[]Invoice) erro
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FetchUserInvoices(pubkey string, invoices *[]Invoice) error {
|
||||
var (
|
||||
rows *sql.Rows
|
||||
invoice Invoice
|
||||
err error
|
||||
)
|
||||
var (
|
||||
query = "" +
|
||||
"SELECT id, pubkey, msats, preimage, hash, bolt11, created_at, expires_at, confirmed_at, held_since, COALESCE(description, ''), " +
|
||||
"CASE WHEN confirmed_at IS NOT NULL THEN 'PAID' WHEN expires_at < CURRENT_TIMESTAMP THEN 'EXPIRED' ELSE 'WAITING' END AS status " +
|
||||
"FROM invoices " +
|
||||
"WHERE pubkey = $1 " +
|
||||
"ORDER BY created_at DESC"
|
||||
)
|
||||
if rows, err = db.Query(query, pubkey); err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
rows.Scan(
|
||||
&invoice.Id, &invoice.Pubkey, &invoice.Msats, &invoice.Preimage, &invoice.Hash,
|
||||
&invoice.PaymentRequest, &invoice.CreatedAt, &invoice.ExpiresAt, &invoice.ConfirmedAt, &invoice.HeldSince, &invoice.Description, &invoice.Status)
|
||||
*invoices = append(*invoices, invoice)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ConfirmInvoice(hash string, confirmedAt time.Time, msatsReceived int) error {
|
||||
if _, err := db.Exec("UPDATE invoices SET confirmed_at = $2, msats_received = $3 WHERE hash = $1", hash, confirmedAt, msatsReceived); err != nil {
|
||||
return err
|
||||
|
63
db/market.go
63
db/market.go
@ -1,6 +1,9 @@
|
||||
package db
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
)
|
||||
|
||||
type FetchOrdersWhere struct {
|
||||
MarketId int
|
||||
@ -8,15 +11,15 @@ type FetchOrdersWhere struct {
|
||||
Confirmed bool
|
||||
}
|
||||
|
||||
func (db *DB) CreateMarket(market *Market) error {
|
||||
if err := db.QueryRow(""+
|
||||
func (db *DB) CreateMarket(tx *sql.Tx, ctx context.Context, market *Market) error {
|
||||
if err := tx.QueryRowContext(ctx, ""+
|
||||
"INSERT INTO markets(description, end_date, invoice_id) "+
|
||||
"VALUES($1, $2, $3) "+
|
||||
"RETURNING id", market.Description, market.EndDate, market.InvoiceId).Scan(&market.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
// For now, we only support binary markets.
|
||||
if _, err := db.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
|
||||
if _, err := tx.Exec("INSERT INTO shares(market_id, description) VALUES ($1, 'YES'), ($1, 'NO')", market.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
@ -62,8 +65,8 @@ func (db *DB) FetchShares(marketId int, shares *[]Share) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FetchShare(shareId string, share *Share) error {
|
||||
return db.QueryRow("SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
|
||||
func (db *DB) FetchShare(tx *sql.Tx, ctx context.Context, shareId string, share *Share) error {
|
||||
return tx.QueryRowContext(ctx, "SELECT id, market_id, description FROM shares WHERE id = $1", shareId).Scan(&share.Id, &share.MarketId, &share.Description)
|
||||
}
|
||||
|
||||
func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
|
||||
@ -99,8 +102,8 @@ func (db *DB) FetchOrders(where *FetchOrdersWhere, orders *[]Order) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateOrder(order *Order) error {
|
||||
if _, err := db.Exec(""+
|
||||
func (db *DB) CreateOrder(tx *sql.Tx, ctx context.Context, order *Order) error {
|
||||
if _, err := tx.ExecContext(ctx, ""+
|
||||
"INSERT INTO orders(share_id, pubkey, side, quantity, price, invoice_id) "+
|
||||
"VALUES ($1, $2, $3, $4, $5, $6)",
|
||||
order.ShareId, order.Pubkey, order.Side, order.Quantity, order.Price, order.InvoiceId); err != nil {
|
||||
@ -108,3 +111,47 @@ func (db *DB) CreateOrder(order *Order) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FetchUserOrders(pubkey string, orders *[]Order) error {
|
||||
query := "" +
|
||||
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, i.confirmed_at, " +
|
||||
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " +
|
||||
"FROM orders o " +
|
||||
"JOIN invoices i ON o.invoice_id = i.id " +
|
||||
"JOIN shares s ON o.share_id = s.id " +
|
||||
"WHERE o.pubkey = $1 AND i.confirmed_at IS NOT NULL " +
|
||||
"ORDER BY o.created_at DESC"
|
||||
rows, err := db.Query(query, pubkey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var order Order
|
||||
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Invoice.ConfirmedAt, &order.Status)
|
||||
*orders = append(*orders, order)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FetchMarketOrders(marketId int64, orders *[]Order) error {
|
||||
query := "" +
|
||||
"SELECT o.id, share_id, o.pubkey, o.side, o.quantity, o.price, o.invoice_id, o.created_at, s.description, s.market_id, " +
|
||||
"CASE WHEN o.order_id IS NOT NULL THEN 'EXECUTED' ELSE 'WAITING' END AS status " +
|
||||
"FROM orders o " +
|
||||
"JOIN shares s ON o.share_id = s.id " +
|
||||
"JOIN invoices i ON i.id = o.invoice_id " +
|
||||
"WHERE s.market_id = $1 AND i.confirmed_at IS NOT NULL " +
|
||||
"ORDER BY o.created_at DESC"
|
||||
rows, err := db.Query(query, marketId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var order Order
|
||||
rows.Scan(&order.Id, &order.ShareId, &order.Pubkey, &order.Side, &order.Quantity, &order.Price, &order.InvoiceId, &order.CreatedAt, &order.ShareDescription, &order.Share.MarketId, &order.Status)
|
||||
*orders = append(*orders, order)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -47,11 +47,13 @@ type (
|
||||
ConfirmedAt null.Time
|
||||
HeldSince null.Time
|
||||
Description string
|
||||
Status string
|
||||
}
|
||||
Order struct {
|
||||
Id UUID
|
||||
CreatedAt time.Time
|
||||
ShareId string `json:"sid"`
|
||||
Id UUID
|
||||
CreatedAt time.Time
|
||||
ShareId string `json:"sid"`
|
||||
ShareDescription string
|
||||
Share
|
||||
Pubkey string
|
||||
Side string `json:"side"`
|
||||
|
@ -2,6 +2,7 @@ package lnd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
@ -12,7 +13,7 @@ import (
|
||||
"github.com/lightningnetwork/lnd/lnwire"
|
||||
)
|
||||
|
||||
func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) {
|
||||
func (lnd *LNDClient) CreateInvoice(tx *sql.Tx, ctx context.Context, d *db.DB, pubkey string, msats int64, description string) (*db.Invoice, error) {
|
||||
var (
|
||||
expiry time.Duration = time.Hour
|
||||
preimage lntypes.Preimage
|
||||
@ -26,14 +27,14 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
|
||||
return nil, err
|
||||
}
|
||||
hash = preimage.Hash()
|
||||
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(context.TODO(), &invoicesrpc.AddInvoiceData{
|
||||
if paymentRequest, err = lnd.Invoices.AddHoldInvoice(ctx, &invoicesrpc.AddInvoiceData{
|
||||
Hash: &hash,
|
||||
Value: lnwire.MilliSatoshi(msats),
|
||||
Expiry: int64(expiry / time.Millisecond),
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lnInvoice, err = lnd.Client.LookupInvoice(context.TODO(), hash); err != nil {
|
||||
if lnInvoice, err = lnd.Client.LookupInvoice(ctx, hash); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dbInvoice = &db.Invoice{
|
||||
@ -46,7 +47,7 @@ func (lnd *LNDClient) CreateInvoice(d *db.DB, pubkey string, msats int64, descri
|
||||
ExpiresAt: lnInvoice.CreationDate.Add(expiry),
|
||||
Description: description,
|
||||
}
|
||||
if err := d.CreateInvoice(dbInvoice); err != nil {
|
||||
if err := d.CreateInvoice(tx, ctx, dbInvoice); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbInvoice, nil
|
||||
|
@ -93,3 +93,18 @@ func HandleInvoice(sc context.ServerContext) echo.HandlerFunc {
|
||||
return sc.Render(c, http.StatusOK, "invoice.html", data)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleInvoices(sc context.ServerContext) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
u db.User
|
||||
invoices []db.Invoice
|
||||
err error
|
||||
)
|
||||
u = c.Get("session").(db.User)
|
||||
if err = sc.Db.FetchUserInvoices(u.Pubkey, &invoices); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, invoices)
|
||||
}
|
||||
}
|
||||
|
@ -1,11 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
context_ "context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.ekzyis.com/ekzyis/delphi.market/db"
|
||||
"git.ekzyis.com/ekzyis/delphi.market/lib"
|
||||
@ -50,6 +52,7 @@ func HandleMarket(sc context.ServerContext) echo.HandlerFunc {
|
||||
func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
tx *sql.Tx
|
||||
u db.User
|
||||
m db.Market
|
||||
invoice *db.Invoice
|
||||
@ -64,26 +67,41 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
||||
return echo.NewHTTPError(http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// transaction start
|
||||
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer tx.Commit()
|
||||
|
||||
u = c.Get("session").(db.User)
|
||||
msats = 1000
|
||||
// TODO: add [market:<id>] for redirect after payment
|
||||
invDescription = fmt.Sprintf("create market \"%s\" (%s)", m.Description, m.EndDate)
|
||||
if invoice, err = sc.Lnd.CreateInvoice(sc.Db, u.Pubkey, msats, invDescription); err != nil {
|
||||
invDescription = fmt.Sprintf("create market \"%s\"", m.Description)
|
||||
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, u.Pubkey, msats, invDescription); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
||||
|
||||
m.InvoiceId = invoice.Id
|
||||
if err := sc.Db.CreateMarket(&m); err != nil {
|
||||
if err := sc.Db.CreateMarket(tx, ctx, &m); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// need to commit before starting to poll invoice status
|
||||
tx.Commit()
|
||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
||||
|
||||
data = map[string]any{
|
||||
"id": invoice.Id,
|
||||
"bolt11": invoice.PaymentRequest,
|
||||
@ -94,9 +112,27 @@ func HandleCreateMarket(sc context.ServerContext) echo.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func HandleMarketOrders(sc context.ServerContext) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
marketId int64
|
||||
orders []db.Order
|
||||
err error
|
||||
)
|
||||
if marketId, err = strconv.ParseInt(c.Param("id"), 10, 64); err != nil {
|
||||
return echo.NewHTTPError(http.StatusBadRequest, "Bad Request")
|
||||
}
|
||||
if err = sc.Db.FetchMarketOrders(marketId, &orders); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, orders)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
tx *sql.Tx
|
||||
u db.User
|
||||
o db.Order
|
||||
s db.Share
|
||||
@ -122,7 +158,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
||||
u = c.Get("session").(db.User)
|
||||
o.Pubkey = u.Pubkey
|
||||
msats = o.Quantity * o.Price * 1000
|
||||
if err = sc.Db.FetchShare(o.ShareId, &s); err != nil {
|
||||
|
||||
// transaction start
|
||||
ctx, cancel := context_.WithTimeout(c.Request().Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
if tx, err = sc.Db.BeginTx(ctx, nil); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
defer tx.Commit()
|
||||
|
||||
if err = sc.Db.FetchShare(tx, ctx, o.ShareId, &s); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
description = fmt.Sprintf("%s %d %s shares @ %d sats [market:%d]", strings.ToUpper(o.Side), o.Quantity, s.Description, o.Price, s.MarketId)
|
||||
@ -130,24 +177,29 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
||||
// TODO: if SELL order, check share balance of user
|
||||
|
||||
// Create HODL invoice
|
||||
if invoice, err = sc.Lnd.CreateInvoice(sc.Db, o.Pubkey, msats, description); err != nil {
|
||||
if invoice, err = sc.Lnd.CreateInvoice(tx, ctx, sc.Db, o.Pubkey, msats, description); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
// Create QR code to pay HODL invoice
|
||||
if qr, err = lib.ToQR(invoice.PaymentRequest); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
if hash, err = lntypes.MakeHashFromStr(invoice.Hash); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Create (unconfirmed) order
|
||||
o.InvoiceId = invoice.Id
|
||||
if err := sc.Db.CreateOrder(&o); err != nil {
|
||||
if err := sc.Db.CreateOrder(tx, ctx, &o); err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
}
|
||||
|
||||
// Start goroutine to poll status and update invoice in background
|
||||
// need to commit before startign to poll invoice status
|
||||
tx.Commit()
|
||||
go sc.Lnd.CheckInvoice(sc.Db, hash)
|
||||
|
||||
// TODO: find matching orders
|
||||
@ -161,3 +213,18 @@ func HandleOrder(sc context.ServerContext) echo.HandlerFunc {
|
||||
return c.JSON(http.StatusPaymentRequired, data)
|
||||
}
|
||||
}
|
||||
|
||||
func HandleOrders(sc context.ServerContext) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
var (
|
||||
u db.User
|
||||
orders []db.Order
|
||||
err error
|
||||
)
|
||||
u = c.Get("session").(db.User)
|
||||
if err = sc.Db.FetchUserOrders(u.Pubkey, &orders); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.JSON(http.StatusOK, orders)
|
||||
}
|
||||
}
|
||||
|
@ -46,10 +46,14 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
||||
middleware.SessionGuard,
|
||||
middleware.LNDGuard)
|
||||
GET(e, sc, "/api/market/:id", handler.HandleMarket)
|
||||
GET(e, sc, "/api/market/:id/orders", handler.HandleMarketOrders)
|
||||
POST(e, sc, "/api/order",
|
||||
handler.HandleOrder,
|
||||
middleware.SessionGuard,
|
||||
middleware.LNDGuard)
|
||||
GET(e, sc, "/api/orders",
|
||||
handler.HandleOrders,
|
||||
middleware.SessionGuard)
|
||||
GET(e, sc, "/api/login", handler.HandleLogin)
|
||||
GET(e, sc, "/api/login/callback", handler.HandleLoginCallback)
|
||||
POST(e, sc, "/api/logout", handler.HandleLogout)
|
||||
@ -57,6 +61,9 @@ func addBackendRoutes(e *echo.Echo, sc ServerContext) {
|
||||
GET(e, sc, "/api/invoice/:id",
|
||||
handler.HandleInvoiceStatus,
|
||||
middleware.SessionGuard)
|
||||
GET(e, sc, "/api/invoices",
|
||||
handler.HandleInvoices,
|
||||
middleware.SessionGuard)
|
||||
}
|
||||
|
||||
func GET(e *echo.Echo, sc ServerContext, path string, scF HandlerFunc, scM ...MiddlewareFunc) *echo.Route {
|
||||
|
@ -12,6 +12,7 @@
|
||||
"core-js": "^3.8.3",
|
||||
"pinia": "^2.1.7",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"s-ago": "^2.2.0",
|
||||
"vite": "^4.5.0",
|
||||
"vue": "^3.2.13",
|
||||
"vue-router": "4"
|
||||
|
@ -22,14 +22,18 @@
|
||||
</figcaption>
|
||||
</figure>
|
||||
<div class="grid text-muted text-xs">
|
||||
<span v-if="faucet" class="mx-3 my-1">faucet</span>
|
||||
<span v-if="faucet" class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||
<a href="https://faucet.mutinynet.com/" target="_blank">faucet.mutinynet.com</a>
|
||||
</span>
|
||||
<span class="mx-3 my-1">payment hash</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||
{{ invoice.Hash }}
|
||||
</span>
|
||||
<span class="mx-3 my-1">created at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||
{{ invoice.CreatedAt }}
|
||||
{{ invoice.CreatedAt }} ({{ ago(new Date(invoice.CreatedAt)) }})
|
||||
</span>
|
||||
<span class="mx-3 my-1">expires at</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||
{{ invoice.ExpiresAt }}
|
||||
{{ invoice.ExpiresAt }} ({{ ago(new Date(invoice.ExpiresAt)) }})
|
||||
</span>
|
||||
<span class="mx-3 my-1">sats</span><span class="text-ellipsis overflow-hidden font-mono me-3 my-1">
|
||||
{{ invoice.Msats / 1000 }}
|
||||
@ -54,8 +58,9 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ago from 's-ago'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -120,6 +125,11 @@ await (async () => {
|
||||
invoice.value = body
|
||||
interval = setInterval(poll, INVOICE_POLL)
|
||||
})()
|
||||
|
||||
onUnmounted(() => { clearInterval(interval) })
|
||||
|
||||
const faucet = window.location.hostname === 'delphi.market' ? 'https://faucet.mutinynet.com' : ''
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
@ -10,62 +10,24 @@
|
||||
</div>
|
||||
<div class="font-mono">{{ market.Description }}</div>
|
||||
<!-- eslint-enable -->
|
||||
<button type="button" :class="yesClass" class="label success font-mono mx-1 my-3"
|
||||
@click.prevent="toggleYes">YES</button>
|
||||
<button type="button" :class="noClass" class="label error font-mono mx-1 my-3" @click.prevent="toggleNo">NO</button>
|
||||
<form v-show="showForm" @submit.prevent="submitForm">
|
||||
<label for="stake">how much?</label>
|
||||
<input name="stake" v-model="stake" type="number" min="0" placeholder="sats" required />
|
||||
<label for="certainty">how sure?</label>
|
||||
<input name="certainty" v-model="certainty" type="number" min="0.01" max="0.99" step="0.01" required />
|
||||
<label>you receive:</label>
|
||||
<label>{{ format(shares) }} {{ selected }} shares @ {{ format(price) }} sats</label>
|
||||
<label>you pay:</label>
|
||||
<label>{{ format(cost) }} sats</label>
|
||||
<label>if you win:</label>
|
||||
<label>{{ format(profit) }} sats</label>
|
||||
<button class="col-span-2" type="submit">submit order</button>
|
||||
</form>
|
||||
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||
<header class="flex flex-row text-center justify-center pt-1">
|
||||
<nav>
|
||||
<StyledLink :to="'/market/' + marketId + '/form'">form</StyledLink>
|
||||
<StyledLink :to="'/market/' + marketId + '/orders'">orders</StyledLink>
|
||||
</nav>
|
||||
</header>
|
||||
<Suspense>
|
||||
<router-view class="m-3" />
|
||||
</Suspense>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import StyledLink from '@/components/StyledLink'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const marketId = route.params.id
|
||||
const selected = ref(null)
|
||||
const showForm = computed(() => selected.value !== null)
|
||||
const yesClass = computed(() => selected.value === 'YES' ? ['active'] : [])
|
||||
const noClass = computed(() => selected.value === 'NO' ? ['active'] : [])
|
||||
const err = ref(null)
|
||||
|
||||
// how much wants the user bet?
|
||||
const stake = ref(null)
|
||||
// how sure is the user he will win?
|
||||
const certainty = ref(null)
|
||||
// price per share: more risk, lower price, higher reward
|
||||
const price = computed(() => certainty.value * 100)
|
||||
// how many (full) shares can be bought?
|
||||
const shares = computed(() => {
|
||||
const val = price.value > 0 ? stake.value / price.value : null
|
||||
// only full shares can be bought
|
||||
return Math.round(val)
|
||||
})
|
||||
// how much does this order cost?
|
||||
const cost = computed(() => {
|
||||
return shares.value * price.value
|
||||
})
|
||||
// how high is the potential reward?
|
||||
const profit = computed(() => {
|
||||
// shares expire at 10 or 0 sats
|
||||
const val = (100 * shares.value) - cost.value
|
||||
return isNaN(val) ? 0 : val
|
||||
})
|
||||
|
||||
const format = (x, i = 3) => x === null ? null : x >= 1 ? Math.round(x) : x === 0 ? x : x.toFixed(i)
|
||||
|
||||
const market = ref(null)
|
||||
const url = '/api/market/' + marketId
|
||||
@ -75,66 +37,16 @@ await fetch(url)
|
||||
market.value = body
|
||||
})
|
||||
.catch(console.error)
|
||||
// Currently, we only support binary markets.
|
||||
// (only events which can be answered with YES and NO)
|
||||
const yesShareId = computed(() => {
|
||||
return market?.value.Shares.find(s => s.Description === 'YES').Id
|
||||
})
|
||||
const noShareId = computed(() => {
|
||||
return market?.value.Shares.find(s => s.Description === 'NO').Id
|
||||
})
|
||||
const shareId = computed(() => {
|
||||
return selected.value === 'YES' ? yesShareId.value : noShareId.value
|
||||
})
|
||||
|
||||
const toggleYes = () => {
|
||||
selected.value = selected.value === 'YES' ? null : 'YES'
|
||||
}
|
||||
|
||||
const toggleNo = () => {
|
||||
selected.value = selected.value === 'NO' ? null : 'NO'
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
// TODO validate form
|
||||
const url = window.origin + '/api/order'
|
||||
const body = JSON.stringify({
|
||||
sid: shareId.value,
|
||||
quantity: shares.value,
|
||||
price: price.value,
|
||||
// TODO support selling
|
||||
side: 'BUY'
|
||||
})
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
|
||||
const resBody = await res.json()
|
||||
if (res.status !== 402) {
|
||||
err.value = `error: server responded with HTTP ${resBody.status}`
|
||||
return
|
||||
}
|
||||
const invoiceId = resBody.id
|
||||
router.push('/invoice/' + invoiceId)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.success.active {
|
||||
background-color: #35df8d;
|
||||
color: white;
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.error.active {
|
||||
background-color: #ff7386;
|
||||
color: white;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
form>* {
|
||||
margin: 0.5em 1em;
|
||||
nav>a {
|
||||
margin: 0 3px;
|
||||
}
|
||||
</style>
|
||||
|
@ -10,6 +10,7 @@
|
||||
<button type="submit">submit</button>
|
||||
</div>
|
||||
</form>
|
||||
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
@ -22,6 +23,7 @@ const router = useRouter()
|
||||
const form = ref(null)
|
||||
const description = ref(null)
|
||||
const endDate = ref(null)
|
||||
const err = ref(null)
|
||||
|
||||
const parseEndDate = endDate => {
|
||||
const [yyyy, mm, dd] = endDate.split('-')
|
||||
@ -33,6 +35,10 @@ const submitForm = async () => {
|
||||
const body = JSON.stringify({ description: description.value, endDate: parseEndDate(endDate.value) })
|
||||
const res = await fetch(url, { method: 'post', headers: { 'Content-type': 'application/json' }, body })
|
||||
const resBody = await res.json()
|
||||
if (res.status !== 402) {
|
||||
err.value = `error: server responded with HTTP ${resBody.status}`
|
||||
return
|
||||
}
|
||||
const invoiceId = resBody.id
|
||||
router.push('/invoice/' + invoiceId)
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<ul>
|
||||
<li class="my-3" v-for="market in markets" :key="market.id">
|
||||
<router-link :to="'/market/' + market.id">{{ market.description }}</router-link>
|
||||
<router-link :to="'/market/' + market.id + '/form'">{{ market.description }}</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
<button v-if="!showForm" @click.prevent="toggleForm">+ create market</button>
|
||||
|
57
vue/src/components/MarketOrders.vue
Normal file
57
vue/src/components/MarketOrders.vue
Normal file
@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<div class="text w-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<th>description</th>
|
||||
<th class="hidden-sm">created at</th>
|
||||
<th>status</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<OrderRow :order="o" v-for="o in orders" :key="o.id" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import OrderRow from './OrderRow.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const marketId = route.params.id
|
||||
|
||||
const orders = ref([])
|
||||
const url = `/api/market/${marketId}/orders`
|
||||
await fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(body => {
|
||||
orders.value = body.map(o => {
|
||||
// remove market column
|
||||
delete o.MarketId
|
||||
return o
|
||||
})
|
||||
})
|
||||
.catch(console.error)
|
||||
</script>
|
||||
|
||||
<style>
|
||||
table {
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
th {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
</style>
|
129
vue/src/components/OrderForm.vue
Normal file
129
vue/src/components/OrderForm.vue
Normal file
@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<button type="button" :class="yesClass" class="label success font-mono mx-1 my-3"
|
||||
@click.prevent="toggleYes">YES</button>
|
||||
<button type="button" :class="noClass" class="label error font-mono mx-1 my-3" @click.prevent="toggleNo">NO</button>
|
||||
<form v-show="showForm" @submit.prevent="submitForm">
|
||||
<label for="stake">how much?</label>
|
||||
<input name="stake" v-model="stake" type="number" min="0" placeholder="sats" required />
|
||||
<label for="certainty">how sure?</label>
|
||||
<input name="certainty" v-model="certainty" type="number" min="0.01" max="0.99" step="0.01" required />
|
||||
<label>you receive:</label>
|
||||
<label>{{ format(shares) }} {{ selected }} shares @ {{ format(price) }} sats</label>
|
||||
<label>you pay:</label>
|
||||
<label>{{ format(cost) }} sats</label>
|
||||
<label>if you win:</label>
|
||||
<label>{{ format(profit) }} sats</label>
|
||||
<button class="col-span-2" type="submit">submit order</button>
|
||||
</form>
|
||||
<div v-if="err" class="red text-center">{{ err }}</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const marketId = route.params.id
|
||||
const selected = ref(null)
|
||||
const showForm = computed(() => selected.value !== null)
|
||||
const yesClass = computed(() => selected.value === 'YES' ? ['active'] : [])
|
||||
const noClass = computed(() => selected.value === 'NO' ? ['active'] : [])
|
||||
const err = ref(null)
|
||||
|
||||
// how much wants the user bet?
|
||||
const stake = ref(100)
|
||||
// how sure is the user he will win?
|
||||
const certainty = ref(0.5)
|
||||
// price per share: more risk, lower price, higher reward
|
||||
const price = computed(() => certainty.value * 100)
|
||||
// how many (full) shares can be bought?
|
||||
const shares = computed(() => {
|
||||
const val = price.value > 0 ? stake.value / price.value : null
|
||||
// only full shares can be bought
|
||||
return Math.round(val)
|
||||
})
|
||||
// how much does this order cost?
|
||||
const cost = computed(() => {
|
||||
return shares.value * price.value
|
||||
})
|
||||
// how high is the potential reward?
|
||||
const profit = computed(() => {
|
||||
// shares expire at 10 or 0 sats
|
||||
const val = (100 * shares.value) - cost.value
|
||||
return isNaN(val) ? 0 : val
|
||||
})
|
||||
|
||||
const format = (x, i = 3) => x === null ? null : x >= 1 ? Math.round(x) : x === 0 ? x : x.toFixed(i)
|
||||
|
||||
const market = ref(null)
|
||||
const url = '/api/market/' + marketId
|
||||
await fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(body => {
|
||||
market.value = body
|
||||
})
|
||||
.catch(console.error)
|
||||
// Currently, we only support binary markets.
|
||||
// (only events which can be answered with YES and NO)
|
||||
const yesShareId = computed(() => {
|
||||
return market?.value.Shares.find(s => s.Description === 'YES').Id
|
||||
})
|
||||
const noShareId = computed(() => {
|
||||
return market?.value.Shares.find(s => s.Description === 'NO').Id
|
||||
})
|
||||
const shareId = computed(() => {
|
||||
return selected.value === 'YES' ? yesShareId.value : noShareId.value
|
||||
})
|
||||
|
||||
const toggleYes = () => {
|
||||
selected.value = selected.value === 'YES' ? null : 'YES'
|
||||
}
|
||||
|
||||
const toggleNo = () => {
|
||||
selected.value = selected.value === 'NO' ? null : 'NO'
|
||||
}
|
||||
|
||||
const submitForm = async () => {
|
||||
// TODO validate form
|
||||
const url = window.origin + '/api/order'
|
||||
const body = JSON.stringify({
|
||||
sid: shareId.value,
|
||||
quantity: shares.value,
|
||||
price: price.value,
|
||||
// TODO support selling
|
||||
side: 'BUY'
|
||||
})
|
||||
const res = await fetch(url, { method: 'POST', headers: { 'Content-type': 'application/json' }, body })
|
||||
const resBody = await res.json()
|
||||
if (res.status !== 402) {
|
||||
err.value = `error: server responded with HTTP ${resBody.status}`
|
||||
return
|
||||
}
|
||||
const invoiceId = resBody.id
|
||||
router.push('/invoice/' + invoiceId)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.success.active {
|
||||
background-color: #35df8d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.error.active {
|
||||
background-color: #ff7386;
|
||||
color: white;
|
||||
}
|
||||
|
||||
form {
|
||||
margin: 0 auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
}
|
||||
|
||||
form>* {
|
||||
margin: 0.5em 1em;
|
||||
}
|
||||
</style>
|
22
vue/src/components/OrderRow.vue
Normal file
22
vue/src/components/OrderRow.vue
Normal file
@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<tr :class="className">
|
||||
<td v-if="order.MarketId"><router-link :to="/market/ + order.MarketId">{{ order.MarketId }}</router-link></td>
|
||||
<td>{{ order.side }} {{ order.quantity }} {{ order.ShareDescription }} @ {{ order.price }} sats</td>
|
||||
<td :title="order.CreatedAt" class="hidden-sm">{{ ago(new Date(order.CreatedAt)) }}</td>
|
||||
<td class="font-mono">{{ order.Status }}</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, defineProps, computed } from 'vue'
|
||||
import ago from 's-ago'
|
||||
|
||||
const props = defineProps(['order'])
|
||||
|
||||
const order = ref(props.order)
|
||||
|
||||
const className = computed(() => {
|
||||
return order.value.side === 'BUY' ? 'success' : 'error'
|
||||
})
|
||||
|
||||
</script>
|
16
vue/src/components/StyledLink.vue
Normal file
16
vue/src/components/StyledLink.vue
Normal file
@ -0,0 +1,16 @@
|
||||
<template>
|
||||
<router-link :to="to" :class="{ selected }">
|
||||
<slot></slot>
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
|
||||
import { computed, defineProps } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
const route = useRoute()
|
||||
const props = defineProps(['to'])
|
||||
const selected = computed(() => props.to !== '/' ? route.path.startsWith(props.to) : route.path === props.to, [route.path])
|
||||
|
||||
</script>
|
60
vue/src/components/UserInvoices.vue
Normal file
60
vue/src/components/UserInvoices.vue
Normal file
@ -0,0 +1,60 @@
|
||||
<template>
|
||||
<div class="text w-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<th>sats</th>
|
||||
<th>created at</th>
|
||||
<th class="hidden-sm">expires at</th>
|
||||
<th>status</th>
|
||||
<th>details</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="i in invoices " :key="i.id">
|
||||
<td>{{ i.Msats / 1000 }}</td>
|
||||
<td :title="i.CreatedAt">{{ ago(new Date(i.CreatedAt)) }}</td>
|
||||
<td :title="i.ExpiresAt" class="hidden-sm">{{ ago(new Date(i.ExpiresAt)) }}</td>
|
||||
<td class="font-mono">{{ i.Status }}</td>
|
||||
<td>
|
||||
<router-link :to="/invoice/ + i.Id">open</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ago from 's-ago'
|
||||
|
||||
const invoices = ref(null)
|
||||
|
||||
const url = '/api/invoices'
|
||||
await fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(body => {
|
||||
invoices.value = body
|
||||
})
|
||||
.catch(console.error)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
th {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
</style>
|
51
vue/src/components/UserOrders.vue
Normal file
51
vue/src/components/UserOrders.vue
Normal file
@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="text w-auto">
|
||||
<table>
|
||||
<thead>
|
||||
<th>market</th>
|
||||
<th>description</th>
|
||||
<th class="hidden-sm">created at</th>
|
||||
<th>status</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<OrderRow :order="o" v-for="o in orders" :key="o.id" />
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import OrderRow from './OrderRow.vue'
|
||||
|
||||
const orders = ref(null)
|
||||
|
||||
const url = '/api/orders'
|
||||
await fetch(url)
|
||||
.then(r => r.json())
|
||||
.then(body => {
|
||||
orders.value = body
|
||||
})
|
||||
.catch(console.error)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
table {
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
th {
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: 600px) {
|
||||
th {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.hidden-sm {
|
||||
display: none
|
||||
}
|
||||
}
|
||||
</style>
|
18
vue/src/components/UserSettings.vue
Normal file
18
vue/src/components/UserSettings.vue
Normal file
@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<div v-if="session.pubkey" class="flex flex-row items-center">
|
||||
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
|
||||
<button class="ms-2 my-3" @click="logout">logout</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useSession } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
const session = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const logout = async () => {
|
||||
await session.logout()
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
@ -50,6 +50,7 @@ a.selected {
|
||||
|
||||
.success:hover {
|
||||
background-color: #35df8d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.red {
|
||||
@ -63,6 +64,7 @@ a.selected {
|
||||
|
||||
.error:hover {
|
||||
background-color: #ff7386;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
|
@ -10,6 +10,11 @@ import LoginView from '@/views/LoginView'
|
||||
import UserView from '@/views/UserView'
|
||||
import MarketView from '@/views/MarketView'
|
||||
import InvoiceView from '@/views/InvoiceView'
|
||||
import UserSettings from '@/components/UserSettings'
|
||||
import UserInvoices from '@/components/UserInvoices'
|
||||
import UserOrders from '@/components/UserOrders'
|
||||
import OrderForm from '@/components/OrderForm'
|
||||
import MarketOrders from '@/components/MarketOrders'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@ -19,10 +24,21 @@ const routes = [
|
||||
path: '/login', component: LoginView
|
||||
},
|
||||
{
|
||||
path: '/user', component: UserView
|
||||
path: '/user',
|
||||
component: UserView,
|
||||
children: [
|
||||
{ path: 'settings', name: 'user', component: UserSettings },
|
||||
{ path: 'invoices', name: 'invoices', component: UserInvoices },
|
||||
{ path: 'orders', name: 'orders', component: UserOrders }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/market/:id', component: MarketView
|
||||
path: '/market/:id',
|
||||
component: MarketView,
|
||||
children: [
|
||||
{ path: 'form', name: 'form', component: OrderForm },
|
||||
{ path: 'orders', name: 'market-orders', component: MarketOrders }
|
||||
]
|
||||
},
|
||||
{
|
||||
path: '/invoice/:id', component: InvoiceView
|
||||
|
@ -9,21 +9,29 @@
|
||||
\__,_|___/\___|_| </pre>
|
||||
</div>
|
||||
<!-- eslint-enable -->
|
||||
<div v-if="session.pubkey">
|
||||
<div>authenticated as {{ session.pubkey.slice(0, 8) }}</div>
|
||||
<button class="my-3" @click="logout">logout</button>
|
||||
</div>
|
||||
<header class="flex flex-row text-center justify-center pt-1">
|
||||
<nav>
|
||||
<StyledLink to="/user/settings">settings</StyledLink>
|
||||
<StyledLink to="/user/invoices">invoices</StyledLink>
|
||||
<StyledLink to="/user/orders">orders</StyledLink>
|
||||
</nav>
|
||||
</header>
|
||||
<Suspense>
|
||||
<router-view class="m-3" />
|
||||
</Suspense>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useSession } from '@/stores/session'
|
||||
import { useRouter } from 'vue-router'
|
||||
const session = useSession()
|
||||
const router = useRouter()
|
||||
import StyledLink from '@/components/StyledLink'
|
||||
</script>
|
||||
|
||||
const logout = async () => {
|
||||
await session.logout()
|
||||
router.push('/')
|
||||
<style scoped>
|
||||
nav {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
</script>
|
||||
nav>a {
|
||||
margin: 0 3px;
|
||||
}
|
||||
</style>
|
||||
|
@ -4012,6 +4012,11 @@ run-parallel@^1.1.9:
|
||||
dependencies:
|
||||
queue-microtask "^1.2.2"
|
||||
|
||||
s-ago@^2.2.0:
|
||||
version "2.2.0"
|
||||
resolved "https://registry.yarnpkg.com/s-ago/-/s-ago-2.2.0.tgz#4143a9d0176b3100dcf649c78e8a1ec8a59b1312"
|
||||
integrity sha512-t6Q/aFCCJSBf5UUkR/WH0mDHX8EGm2IBQ7nQLobVLsdxOlkryYMbOlwu2D4Cf7jPUp0v1LhfPgvIZNoi9k8lUA==
|
||||
|
||||
safe-array-concat@^1.0.1:
|
||||
version "1.0.1"
|
||||
resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.0.1.tgz#91686a63ce3adbea14d61b14c99572a8ff84754c"
|
||||
|
Loading…
x
Reference in New Issue
Block a user