Have got first run working and publishing to Netlify

This commit is contained in:
Leon Mika 2026-02-26 22:23:47 +11:00
parent b7e0269e9d
commit 30d99eeb9e
22 changed files with 472 additions and 47 deletions

View file

@ -0,0 +1,66 @@
import { Controller } from "@hotwired/stimulus"
import { showToast } from "../services/toast";
export default class FirstRunController extends Controller {
static targets = ['pages'];
connect() {
this.pagesTargets.forEach((x) => x.classList.add('d-none'));
this.pagesTargets[0].classList.remove('d-none');
this.element.querySelector('input[name="username"]').focus();
}
nextPage(ev) {
ev.preventDefault();
const currentIndex = this.pagesTargets.findIndex(x => !x.classList.contains('d-none'));
if (currentIndex === -1) {
return;
}
if (!this._validate(currentIndex)) {
return;
}
let nextPage = currentIndex + 1;
if (nextPage >= this.pagesTargets.length) {
this.element.querySelector('form').submit();
return;
}
this.pagesTargets[currentIndex].classList.add('d-none');
this.pagesTargets[nextPage].classList.remove('d-none');
if (nextPage === 1) {
this.element.querySelector('input[name="siteName"]').focus();
}
}
_validate(pageNumber) {
let newUsername = this.element.querySelector('input[name="username"]');
let newPassword1 = this.element.querySelector('input[name="password1"]');
let newPassword2 = this.element.querySelector('input[name="password2"]');
if (newUsername.value === '') {
alert('Please enter a username');
newUsername.focus();
return false;
}
if (!newUsername.value.match(/^[a-zA-Z0-9_-]+$/)) {
alert('Please enter a username with letters, numbers, underscores, and dashes only');
newUsername.focus();
newUsername.select();
return false;
}
if (newPassword1.value === '') {
alert('Please enter a password');
newPassword1.focus();
return false;
}
if (newPassword2.value !== newPassword1.value) {
alert('Passwords do not match');
newPassword2.focus();
return false;
}
return true;
}
}

View file

@ -4,9 +4,11 @@ import ToastController from "./controllers/toast";
import PostlistController from "./controllers/postlist";
import PosteditController from "./controllers/postedit";
import LogoutController from "./controllers/logout";
import FirstRunController from "./controllers/firstrun";
window.Stimulus = Application.start()
Stimulus.register("toast", ToastController);
Stimulus.register("postlist", PostlistController);
Stimulus.register("postedit", PosteditController);
Stimulus.register("logout", LogoutController);
Stimulus.register("first-run", FirstRunController);

1
go.mod
View file

@ -37,6 +37,7 @@ require (
github.com/go-openapi/strfmt v0.19.11 // indirect
github.com/go-openapi/swag v0.19.12 // indirect
github.com/go-openapi/validate v0.20.0 // indirect
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 // indirect
github.com/go-stack/stack v1.8.0 // indirect
github.com/gofiber/fiber/v3 v3.1.0 // indirect
github.com/gofiber/schema v1.7.0 // indirect

2
go.sum
View file

@ -178,6 +178,8 @@ github.com/go-openapi/validate v0.19.12/go.mod h1:Rzou8hA/CBw8donlS6WNEUQupNvUZ0
github.com/go-openapi/validate v0.19.15/go.mod h1:tbn/fdOwYHgrhPBzidZfJC2MIVvs9GA7monOmWBbeCI=
github.com/go-openapi/validate v0.20.0 h1:pzutNCCBZGZlE+u8HD3JZyWdc/TVbtVwlWUp8/vgUKk=
github.com/go-openapi/validate v0.20.0/go.mod h1:b60iJT+xNNLfaQJUqLI7946tYiFEOuE9E4k54HpKcJ0=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es=
github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0 h1:5SgMzNM5HxrEjV0ww2lTmX6E2Izsfxas4+YHWRs3Lsk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=

77
handlers/index.go Normal file
View file

@ -0,0 +1,77 @@
package handlers
import (
"fmt"
"net/url"
"regexp"
"emperror.dev/errors"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/session"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/services/sites"
)
var sitePath = regexp.MustCompile(`^/sites/([0-9]+)`)
type IndexHandler struct {
SiteService *sites.Service
}
func (h IndexHandler) Index(c fiber.Ctx) error {
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
if err != nil {
return err
} else if !hasUserAndSites {
return c.Redirect().To("/first-run")
}
user, hasUser := models.GetUser(c.Context())
if !hasUser {
return c.Redirect().To("/login")
}
if refUrl, rerr := url.Parse(c.Get("Referer")); rerr == nil {
if parts := sitePath.FindStringSubmatch(refUrl.Path); len(parts) == 2 {
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", parts[1]))
}
}
site, err := h.SiteService.BestSite(c.Context(), user)
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
}
func (h IndexHandler) FirstRun(c fiber.Ctx) error {
hasUserAndSites, err := h.SiteService.HasUsersAsSites(c.Context())
if err != nil {
return err
} else if hasUserAndSites {
return errors.New("you already have a site")
}
return c.Render("index/first-run", fiber.Map{}, "layouts/bare_with_scripts")
}
func (h IndexHandler) FirstRunSubmit(c fiber.Ctx) error {
var req sites.FirstRunRequest
if err := c.Bind().Body(&req); err != nil {
return errors.Wrap(err, "failed to parse first run request")
}
sess := session.FromContext(c)
if err := sess.Regenerate(); err != nil {
return c.Status(fiber.StatusInternalServerError).SendString("Failed to login")
}
user, site, err := h.SiteService.FirstRun(c.Context(), req)
if err != nil {
return err
}
sess.Set("user_id", user.ID)
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", site.ID))
}

View file

@ -66,6 +66,7 @@ func (lh *LoginHandler) DoLogin(c fiber.Ctx) error {
}
sess.Set("user_id", user.ID)
sess.Delete("_login_challenge")
return c.Redirect().To("/")
}

View file

@ -7,7 +7,27 @@ import (
"lmika.dev/lmika/weiro/services/auth"
)
func AuthUser(auth *auth.Service) func(c fiber.Ctx) error {
func OptionalUser(auth *auth.Service) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
sess := session.FromContext(c)
userID, _ := sess.Get("user_id").(int64)
if userID == 0 {
return c.Next()
}
user, err := auth.GetUser(c.Context(), userID)
if err != nil {
return c.Next()
}
c.Locals("user", user)
c.SetContext(models.WithUser(c.Context(), user))
return c.Next()
}
}
func RequireUser(auth *auth.Service) func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
sess := session.FromContext(c)
userID, _ := sess.Get("user_id").(int64)

12
main.go
View file

@ -26,6 +26,7 @@ import (
"lmika.dev/lmika/weiro/services/auth"
"lmika.dev/lmika/weiro/services/posts"
"lmika.dev/lmika/weiro/services/publisher"
"lmika.dev/lmika/weiro/services/sites"
_ "modernc.org/sqlite"
)
@ -49,6 +50,7 @@ func main() {
publisherSvc := publisher.New(dbp)
publisherQueue := publisher.NewQueue(publisherSvc)
postService := posts.New(dbp, publisherQueue)
siteService := sites.New(dbp)
// CLI tools
if *flagPasswd != "" && *flagUser != "" {
@ -120,6 +122,7 @@ func main() {
},
}))
ih := handlers.IndexHandler{SiteService: siteService}
lh := handlers.LoginHandler{Config: cfg, AuthService: authSvc}
ph := handlers.PostsHandler{PostService: postService}
@ -127,7 +130,7 @@ func main() {
app.Post("/login", lh.DoLogin)
app.Post("/logout", lh.Logout)
siteGroup := app.Group("/sites/:siteID", middleware.AuthUser(authSvc), middleware.RequiresSite(dbp))
siteGroup := app.Group("/sites/:siteID", middleware.RequireUser(authSvc), middleware.RequiresSite(dbp))
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
@ -136,9 +139,10 @@ func main() {
siteGroup.Patch("/posts/:postID", ph.Patch)
siteGroup.Delete("/posts/:postID", ph.Delete)
app.Get("/", func(c fiber.Ctx) error {
return c.Redirect().To("/sites/1/posts")
})
app.Get("/", middleware.OptionalUser(authSvc), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
// TEMP

View file

@ -1,5 +1,7 @@
package models
import "time"
type PublishTargetType int
const (
@ -11,10 +13,10 @@ const (
type Site struct {
ID int64
OwnerID int64
Created time.Time
Title string
Tagline string
//Meta SiteMeta
//Posts []*Post
}
type SitePublishTarget struct {

View file

@ -1,16 +1,20 @@
package models
import (
"regexp"
"time"
"golang.org/x/crypto/bcrypt"
)
var ValidUserName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
type User struct {
ID int64
Username string
PasswordHashed []byte
TimeZone string
Created time.Time
}
func (u *User) SetPassword(pwd string) {

View file

@ -29,14 +29,16 @@ type PublishTarget struct {
}
type Site struct {
ID int64
OwnerID int64
Title string
Tagline string
ID int64
OwnerID int64
Title string
Tagline string
CreatedAt int64
}
type User struct {
ID int64
Username string
Password string
ID int64
Username string
Password string
CreatedAt int64
}

View file

@ -7,32 +7,51 @@ package sqlgen
import (
"context"
"database/sql"
)
const hasUsersAndSites = `-- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites
`
func (q *Queries) HasUsersAndSites(ctx context.Context) (sql.NullBool, error) {
row := q.db.QueryRowContext(ctx, hasUsersAndSites)
var has_users_and_sites sql.NullBool
err := row.Scan(&has_users_and_sites)
return has_users_and_sites, err
}
const insertSite = `-- name: InsertSite :one
INSERT INTO sites (
owner_id,
title,
tagline
) VALUES (?, ?, ?)
tagline,
created_at
) VALUES (?, ?, ?, ?)
RETURNING id
`
type InsertSiteParams struct {
OwnerID int64
Title string
Tagline string
OwnerID int64
Title string
Tagline string
CreatedAt int64
}
func (q *Queries) InsertSite(ctx context.Context, arg InsertSiteParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertSite, arg.OwnerID, arg.Title, arg.Tagline)
row := q.db.QueryRowContext(ctx, insertSite,
arg.OwnerID,
arg.Title,
arg.Tagline,
arg.CreatedAt,
)
var id int64
err := row.Scan(&id)
return id, err
}
const selectSiteByID = `-- name: SelectSiteByID :one
SELECT id, owner_id, title, tagline FROM sites WHERE id = ?
SELECT id, owner_id, title, tagline, created_at FROM sites WHERE id = ?
`
func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
@ -43,12 +62,13 @@ func (q *Queries) SelectSiteByID(ctx context.Context, id int64) (Site, error) {
&i.OwnerID,
&i.Title,
&i.Tagline,
&i.CreatedAt,
)
return i, err
}
const selectSitesOwnedByUser = `-- name: SelectSitesOwnedByUser :many
SELECT id, owner_id, title, tagline FROM sites WHERE owner_id = ?
SELECT id, owner_id, title, tagline, created_at FROM sites WHERE owner_id = ? ORDER BY title ASC
`
func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]Site, error) {
@ -65,6 +85,7 @@ func (q *Queries) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) ([]
&i.OwnerID,
&i.Title,
&i.Tagline,
&i.CreatedAt,
); err != nil {
return nil, err
}

View file

@ -10,40 +10,51 @@ import (
)
const insertUser = `-- name: InsertUser :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id
`
type InsertUserParams struct {
Username string
Password string
Username string
Password string
CreatedAt int64
}
func (q *Queries) InsertUser(ctx context.Context, arg InsertUserParams) (int64, error) {
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password)
row := q.db.QueryRowContext(ctx, insertUser, arg.Username, arg.Password, arg.CreatedAt)
var id int64
err := row.Scan(&id)
return id, err
}
const selectUserByID = `-- name: SelectUserByID :one
SELECT id, username, password FROM users WHERE id = ? LIMIT 1
SELECT id, username, password, created_at FROM users WHERE id = ? LIMIT 1
`
func (q *Queries) SelectUserByID(ctx context.Context, id int64) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByID, id)
var i User
err := row.Scan(&i.ID, &i.Username, &i.Password)
err := row.Scan(
&i.ID,
&i.Username,
&i.Password,
&i.CreatedAt,
)
return i, err
}
const selectUserByUsername = `-- name: SelectUserByUsername :one
SELECT id, username, password FROM users WHERE username = ? LIMIT 1
SELECT id, username, password, created_at FROM users WHERE username = ? LIMIT 1
`
func (q *Queries) SelectUserByUsername(ctx context.Context, username string) (User, error) {
row := q.db.QueryRowContext(ctx, selectUserByUsername, username)
var i User
err := row.Scan(&i.ID, &i.Username, &i.Password)
err := row.Scan(
&i.ID,
&i.Username,
&i.Password,
&i.CreatedAt,
)
return i, err
}

View file

@ -2,6 +2,7 @@ package db
import (
"context"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -18,6 +19,7 @@ func (db *Provider) SelectSiteByID(ctx context.Context, id int64) (models.Site,
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
Created: time.Unix(row.CreatedAt, 0).UTC(),
}, nil
}
@ -34,6 +36,7 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
OwnerID: row.OwnerID,
Title: row.Title,
Tagline: row.Tagline,
Created: time.Unix(row.CreatedAt, 0).UTC(),
}
}
return sites, nil
@ -42,9 +45,10 @@ func (db *Provider) SelectSitesOwnedByUser(ctx context.Context, ownerID int64) (
func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
if site.ID == 0 {
newID, err := db.queries.InsertSite(ctx, sqlgen.InsertSiteParams{
OwnerID: site.OwnerID,
Title: site.Title,
Tagline: site.Tagline,
OwnerID: site.OwnerID,
Title: site.Title,
Tagline: site.Tagline,
CreatedAt: timeToInt(site.Created),
})
if err != nil {
return err
@ -56,3 +60,11 @@ func (db *Provider) SaveSite(ctx context.Context, site *models.Site) error {
// No update query defined in sqlgen yet
return nil
}
func (db *Provider) HasUsersAndSites(ctx context.Context) (bool, error) {
nullBool, err := db.queries.HasUsersAndSites(ctx)
if err != nil {
return false, err
}
return nullBool.Valid && nullBool.Bool, nil
}

View file

@ -3,6 +3,7 @@ package db
import (
"context"
"encoding/base64"
"time"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db/gen/sqlgen"
@ -31,8 +32,9 @@ func (db *Provider) SaveUser(ctx context.Context, user *models.User) error {
if user.ID == 0 {
newID, err := db.queries.InsertUser(ctx, sqlgen.InsertUserParams{
Username: user.Username,
Password: hashedPassword,
Username: user.Username,
Password: hashedPassword,
CreatedAt: timeToInt(user.Created),
})
if err != nil {
return err
@ -58,5 +60,6 @@ func dbUserToUser(res sqlgen.User) (models.User, error) {
ID: res.ID,
Username: res.Username,
PasswordHashed: pwdBytes,
Created: time.Unix(res.CreatedAt, 0).UTC(),
}, nil
}

103
services/sites/services.go Normal file
View file

@ -0,0 +1,103 @@
package sites
import (
"context"
"time"
"emperror.dev/errors"
"github.com/go-ozzo/ozzo-validation/v4"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
)
type Service struct {
db *db.Provider
}
func New(dbp *db.Provider) *Service {
return &Service{
db: dbp,
}
}
func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
return s.db.HasUsersAndSites(ctx)
}
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
return models.Site{}, err
} else if len(sites) == 0 {
return models.Site{}, errors.New("no sites found")
}
return sites[0], nil
}
type FirstRunRequest struct {
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
SiteName string `form:"siteName"`
SiteURL string `form:"siteUrl"`
NetlifySiteID string `form:"netlifySiteId"`
NetlifyAPIKey string `form:"netlifyAPIToken"`
}
func (frr FirstRunRequest) Validate() error {
return validation.ValidateStruct(&frr,
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
validation.Field(&frr.Password1, validation.Required),
validation.Field(&frr.Password2, validation.Required, validation.By(stringEquals(frr.Password1))),
)
}
func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser models.User, newSite models.Site, _ error) {
if err := req.Validate(); err != nil {
return newUser, newSite, err
}
hasSite, err := s.db.HasUsersAndSites(ctx)
if err != nil {
return newUser, newSite, err
} else if hasSite {
return newUser, newSite, errors.New("user and site already exists")
}
newUser = models.User{
Username: req.Username,
TimeZone: "UTC",
Created: time.Now(),
}
newUser.SetPassword(req.Password1)
if err := s.db.SaveUser(ctx, &newUser); err != nil {
return newUser, newSite, err
}
newSite = models.Site{
Title: defaultIfEmpty(req.SiteName, "New Site"),
OwnerID: newUser.ID,
Created: time.Now(),
}
if err := s.db.SaveSite(ctx, &newSite); err != nil {
return newUser, newSite, err
}
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
if hasNetlifyConfig {
target := models.SitePublishTarget{
SiteID: newSite.ID,
Enabled: true,
BaseURL: req.SiteURL,
TargetType: "netlify",
TargetRef: req.NetlifySiteID,
TargetKey: req.NetlifyAPIKey,
}
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
return newUser, newSite, err
}
}
return newUser, newSite, nil
}

23
services/sites/utils.go Normal file
View file

@ -0,0 +1,23 @@
package sites
import (
"emperror.dev/errors"
validation "github.com/go-ozzo/ozzo-validation/v4"
)
func stringEquals(str string) validation.RuleFunc {
return func(value interface{}) error {
s, _ := value.(string)
if s != str {
return errors.New("unexpected string")
}
return nil
}
}
func defaultIfEmpty(value string, defaultValue string) string {
if value == "" {
return defaultValue
}
return value
}

View file

@ -1,5 +1,5 @@
-- name: SelectSitesOwnedByUser :many
SELECT * FROM sites WHERE owner_id = ?;
SELECT * FROM sites WHERE owner_id = ? ORDER BY title ASC;
-- name: SelectSiteByID :one
SELECT * FROM sites WHERE id = ?;
@ -8,6 +8,10 @@ SELECT * FROM sites WHERE id = ?;
INSERT INTO sites (
owner_id,
title,
tagline
) VALUES (?, ?, ?)
tagline,
created_at
) VALUES (?, ?, ?, ?)
RETURNING id;
-- name: HasUsersAndSites :one
SELECT (SELECT COUNT(*) FROM users) > 0 AND (SELECT COUNT(*) FROM sites) > 0 AS has_users_and_sites;

View file

@ -5,7 +5,7 @@ SELECT * FROM users WHERE username = ? LIMIT 1;
SELECT * FROM users WHERE id = ? LIMIT 1;
-- name: InsertUser :one
INSERT INTO users (username, password) VALUES (?, ?) RETURNING id;
INSERT INTO users (username, password, created_at) VALUES (?, ?, ?) RETURNING id;
-- name: UpdateUser :exec
UPDATE users SET username = ?, password = ? WHERE id = ?;

View file

@ -1,15 +1,17 @@
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
password TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE UNIQUE INDEX idx_users_username ON users (username);
CREATE TABLE sites (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
title TEXT NOT NULL,
tagline TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER NOT NULL,
title TEXT NOT NULL,
tagline TEXT NOT NULL,
created_at INTEGER NOT NULL,
FOREIGN KEY (owner_id) REFERENCES users (id) ON DELETE CASCADE
);

View file

@ -0,0 +1,52 @@
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
<div class="text-center mb-4">
<h4>Welcome to</h4>
<h1>Weiro</h1>
</div>
<form action="/first-run" method="post">
<div data-first-run-target="pages needs-validation">
<div class="text-center mb-4">
<p>Please enter the username and password you'd like to use to login.</p>
</div>
<div class="mb-2">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" name="username" id="username">
</div>
<div class="mb-3">
<label for="password1" class="form-label">Password</label>
<input type="password" class="form-control" name="password1" id="password1">
</div>
<div class="mb-3">
<label for="password2" class="form-label">Re-enter password</label>
<input type="password" class="form-control" name="password2" id="password2">
</div>
<div class="mb-3 text-end">
<button class="btn btn-primary" value="Next" data-action="click->first-run#nextPage">Next »</button>
</div>
</div>
<div data-first-run-target="pages">
<div class="text-center mb-4">
<p>Enter the details of your blog, if you know them.<br>All fields are optional, and can be changed later.</p>
</div>
<div class="mb-2">
<label for="siteName" class="form-label">Site Name</label>
<input type="text" class="form-control" name="siteName" id="siteName">
</div>
<div class="mb-3">
<label for="siteUrl" class="form-label">Site URL</label>
<input type="text" class="form-control" name="siteUrl" id="siteUrl">
</div>
<div class="mb-3">
<label for="netlifySiteId" class="form-label">Netlify Site ID</label>
<input type="text" class="form-control" name="netlifySiteId" id="netlifySiteId">
</div>
<div class="mb-3">
<label for="netlifyAPIToken" class="form-label">Netlify API Token</label>
<input type="text" class="form-control" name="netlifyAPIToken" id="netlifyAPIToken">
</div>
<div class="mb-3 text-end">
<input type="submit" class="btn btn-primary" value="Finish">
</div>
</div>
</form>
</div>

View file

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Weiro</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/static/assets/main.css">
<script src="/static/assets/main.js" type="module"></script>
</head>
<body class="min-vh-100 d-flex flex-column">
{{ embed }}
</body>
</html>