Added a site picker #6

Merged
lmika merged 2 commits from feature/site-picker into main 2026-03-24 09:24:56 +00:00
16 changed files with 215 additions and 29 deletions

View file

@ -10,6 +10,15 @@ $container-max-widths: (
@import "bootstrap/scss/bootstrap.scss";
// Navbar
.navbar-site-visit {
display: inline-block;
line-height: 2em;
margin-bottom: 4px;
margin-right: 10px;
}
// Post list
.postlist .post img {

View file

@ -1,3 +1,4 @@
import feather from "feather-icons/dist/feather.js";
import { Application } from "@hotwired/stimulus";
import ToastController from "./controllers/toast";
@ -18,3 +19,5 @@ Stimulus.register("first-run", FirstRunController);
Stimulus.register("upload", UploadController);
Stimulus.register("show-upload", ShowUploadController);
Stimulus.register("pagelist", PagelistController);
feather.replace();

View file

@ -119,7 +119,17 @@ Starting weiro without any arguments will start the server.
app.Post("/login", lh.DoLogin)
app.Post("/logout", lh.Logout)
siteGroup := app.Group("/sites/:siteID", middleware.LogErrors(), middleware.RequireUser(svcs.Auth), middleware.RequiresSite(svcs.Sites))
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
app.Use(middleware.LogErrors(), middleware.RequireUser(svcs.Auth))
app.Get("/sites/new", ssh.New)
app.Post("/sites", ssh.Create)
siteGroup := app.Group("/sites/:siteID", middleware.RequiresSite(svcs.Sites))
siteGroup.Get("/posts", ph.Index)
siteGroup.Get("/posts/new", ph.New)
@ -158,12 +168,6 @@ Starting weiro without any arguments will start the server.
siteGroup.Post("/pages/:pageID", pgh.Update)
siteGroup.Post("/pages/:pageID/delete", pgh.Delete)
app.Get("/", middleware.OptionalUser(svcs.Auth), ih.Index)
app.Get("/first-run", ih.FirstRun)
app.Post("/first-run", ih.FirstRunSubmit)
app.Get("/static/*", static.New("./static"))
if err := app.Listen(":3000"); err != nil {
log.Println(err)
}

View file

@ -9,7 +9,7 @@ import (
func LogErrors() func(c fiber.Ctx) error {
return func(c fiber.Ctx) error {
if err := c.Next(); err != nil {
log.Printf("error: %v\n", err)
log.Printf("%v: error: %v\n", c.Path(), err)
return err
}
return nil

View file

@ -32,9 +32,19 @@ func RequiresSite(sites *sites.Service) func(c fiber.Ctx) error {
return err
}
}
c.Locals("site", site)
c.SetContext(models.WithSite(c.Context(), site))
sitesOwnedByUser, err := sites.ListSites(c.Context())
if err != nil {
return err
}
c.Locals("allSites", sitesOwnedByUser)
if pubTargets, err := sites.BestPubTarget(c.Context(), site); err == nil {
c.Locals("pubTarget", pubTargets)
}
return c.Next()
}
}

View file

@ -12,10 +12,28 @@ type SiteSettingsHandler struct {
SiteService *sites.Service
}
func (s *SiteSettingsHandler) General(ctx fiber.Ctx) error {
site := ctx.Locals("site").(models.Site)
func (s *SiteSettingsHandler) New(c fiber.Ctx) error {
return c.Render("sitesettings/new", fiber.Map{}, "layouts/bare_with_scripts")
}
return ctx.Render("sitesettings/general", fiber.Map{
func (s *SiteSettingsHandler) Create(c fiber.Ctx) error {
var params sites.CreateSiteParams
if err := c.Bind().Body(&params); err != nil {
return err
}
newSite, err := s.SiteService.CreateSite(c.Context(), params)
if err != nil {
return err
}
return c.Redirect().To(fmt.Sprintf("/sites/%v/posts", newSite.ID))
}
func (s *SiteSettingsHandler) General(c fiber.Ctx) error {
site := c.Locals("site").(models.Site)
return c.Render("sitesettings/general", fiber.Map{
"site": site,
"tzones": sites.ListZones(),
})

View file

@ -13,6 +13,13 @@
<header>
<h1>{{ .Site.Title }}</h1>
<p>{{ .Site.Tagline }}</p>
{{ if .Site.NavItems }}
<nav>
{{ range .Site.NavItems }}
{{ if .ShowInNav }}<a href="{{ url_abs .Slug }}">{{ .Title }}</a>{{ end }}
{{ end }}
</nav>
{{ end }}
</header>
<main>

View file

@ -6,6 +6,7 @@ import (
"iter"
"lmika.dev/lmika/weiro/models"
"lmika.dev/pkg/modash/moslice"
)
type Site struct {
@ -20,3 +21,7 @@ type Site struct {
CategoriesOfPost func(ctx context.Context, postID int64) ([]*models.Category, error)
Pages []*models.Page
}
func (s Site) NavItems() []*models.Page {
return moslice.Filter(s.Pages, func(p *models.Page) bool { return p.ShowInNav })
}

36
package-lock.json generated
View file

@ -7,7 +7,8 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
"esbuild-sass-plugin": "^3.6.0",
"feather-icons": "^4.29.2"
},
"devDependencies": {
"esbuild": "0.27.3"
@ -783,6 +784,12 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/colorjs.io": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
@ -790,6 +797,17 @@
"license": "MIT",
"peer": true
},
"node_modules/core-js": {
"version": "3.49.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz",
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
@ -855,6 +873,16 @@
"sass-embedded": "^1.97.2"
}
},
"node_modules/feather-icons": {
"version": "4.29.2",
"resolved": "https://registry.npmjs.org/feather-icons/-/feather-icons-4.29.2.tgz",
"integrity": "sha512-0TaCFTnBTVCz6U+baY2UJNKne5ifGh7sMG4ZC2LoBWCZdIyPa+y6UiR4lEYGws1JOFWdee8KAsAIvu0VcXqiqA==",
"license": "MIT",
"dependencies": {
"classnames": "^2.2.5",
"core-js": "^3.1.3"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@ -887,9 +915,9 @@
}
},
"node_modules/immutable": {
"version": "5.1.4",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
"integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
"version": "5.1.5",
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz",
"integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==",
"license": "MIT"
},
"node_modules/is-core-module": {

View file

@ -5,6 +5,7 @@
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"bootstrap": "^5.3.8",
"esbuild-sass-plugin": "^3.6.0"
"esbuild-sass-plugin": "^3.6.0",
"feather-icons": "^4.29.2"
}
}

View file

@ -9,6 +9,7 @@ import (
"github.com/gofiber/fiber/v3"
"lmika.dev/lmika/weiro/models"
"lmika.dev/lmika/weiro/providers/db"
"lmika.dev/pkg/modash/moslice"
)
type Service struct {
@ -25,6 +26,22 @@ func (s *Service) HasUsersAsSites(ctx context.Context) (bool, error) {
return s.db.HasUsersAndSites(ctx)
}
func (s *Service) ListSites(ctx context.Context) ([]models.Site, error) {
user, ok := models.GetUser(ctx)
if !ok {
return nil, models.UserRequiredError
}
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
return nil, err
} else if len(sites) == 0 {
return nil, errors.New("no sites found")
}
return sites, nil
}
func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site, error) {
sites, err := s.db.SelectSitesOwnedByUser(ctx, user.ID)
if err != nil {
@ -36,16 +53,20 @@ func (s *Service) BestSite(ctx context.Context, user models.User) (models.Site,
return sites[0], nil
}
type FirstRunRequest struct {
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
type CreateSiteParams struct {
SiteName string `form:"siteName"`
SiteURL string `form:"siteUrl"`
NetlifySiteID string `form:"netlifySiteId"`
NetlifyAPIKey string `form:"netlifyAPIToken"`
}
type FirstRunRequest struct {
CreateSiteParams
Username string `form:"username"`
Password1 string `form:"password1"`
Password2 string `form:"password2"`
}
func (frr FirstRunRequest) Validate() error {
return validation.ValidateStruct(&frr,
validation.Field(&frr.Username, validation.Required, validation.Match(models.ValidUserName)),
@ -76,16 +97,31 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
return newUser, newSite, err
}
ctx = models.WithUser(ctx, newUser)
newSite, err = s.CreateSite(ctx, req.CreateSiteParams)
if err != nil {
return newUser, newSite, err
}
return newUser, newSite, nil
}
func (s *Service) CreateSite(ctx context.Context, req CreateSiteParams) (newSite models.Site, _ error) {
user, ok := models.GetUser(ctx)
if !ok {
return newSite, models.UserRequiredError
}
newSite = models.Site{
Title: defaultIfEmpty(req.SiteName, "New Site"),
GUID: models.NewNanoID(),
OwnerID: newUser.ID,
OwnerID: user.ID,
Timezone: "UTC",
PostsPerPage: 10,
Created: time.Now(),
}
if err := s.db.SaveSite(ctx, &newSite); err != nil {
return newUser, newSite, err
return newSite, err
}
hasNetlifyConfig := req.SiteURL != "" && req.NetlifySiteID != "" && req.NetlifyAPIKey != ""
@ -100,11 +136,11 @@ func (s *Service) FirstRun(ctx context.Context, req FirstRunRequest) (newUser mo
TargetKey: req.NetlifyAPIKey,
}
if err := s.db.SavePublishTarget(ctx, &target); err != nil {
return newUser, newSite, err
return newSite, err
}
}
return newUser, newSite, nil
return newSite, nil
}
func (s *Service) GetSiteByID(ctx context.Context, siteID int64) (models.Site, error) {
@ -166,3 +202,17 @@ func (s *Service) UpdateSiteSettings(ctx context.Context, params UpdateSiteSetti
return site, nil
}
func (s *Service) BestPubTarget(ctx context.Context, site models.Site) (models.SitePublishTarget, error) {
pubTargets, err := s.db.SelectPublishTargetsOfSite(ctx, site.ID)
if err != nil {
return models.SitePublishTarget{}, err
}
enabledPubTargets := moslice.Filter(pubTargets, func(pubTarget models.SitePublishTarget) bool { return pubTarget.Enabled })
if len(enabledPubTargets) == 0 {
return models.SitePublishTarget{}, errors.New("no publish targets found")
}
return enabledPubTargets[0], nil
}

View file

@ -29,7 +29,25 @@
<span class="visually-hidden">Publishing...</span>
</div>
-->
<div class="nav-item dropdown me-2">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ .site.Title }}
</a>
<ul class="dropdown-menu dropdown-menu-end">
{{ range .allSites }}
<li><a class="dropdown-item" href="/sites/{{.ID}}/posts">{{.Title}}</a></li>
{{ end }}
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="/sites/new">New Site…</a></li>
</ul>
</div>
<div class="nav-item dropdown border-end me-3">
{{ if .pubTarget }}
<a href="{{.pubTarget.BaseURL}}" class="nav-link navbar-site-visit" target="_blank" title="Visit site">
<i data-feather="external-link" width="18" height="18"></i>
</a>
{{ end }}
</div>
<div class="nav-item dropdown">
<a class="nav-link dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">
{{ .user.Username }}

View file

@ -26,7 +26,7 @@
</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>
<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>

View file

@ -29,7 +29,7 @@
</table>
{{ else }}
<div class="h4 m-3 text-center">
<div class="position-absolute top-50 start-50 translate-middle">No pages yet.</div>
<div class="position-absolute top-50 start-50 translate-middle">📄<br>No pages yet.</div>
</div>
{{ end }}
</main>

View file

@ -0,0 +1,29 @@
<div class="mx-auto p-2" style="width: 400px; margin-block-start: 50px;" data-controller="first-run">
<div class="text-center mb-4">
<h1>New Site</h1>
</div>
<form action="/sites" method="post">
<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="Create Site">
</div>
</form>
</div>

View file

@ -20,5 +20,9 @@
</div>
{{ end }}
</div>
{{ else }}
<div class="h4 m-3 text-center">
<div class="position-absolute top-50 start-50 translate-middle">🖼️<br>No uploads yet.</div>
</div>
{{ end }}
</main>