repos / pico

pico services - prose.sh, pastes.sh, imgs.sh, feeds.sh, pgs.sh
git clone https://github.com/picosh/pico.git

commit
65ca2e2
parent
886ea6e
author
Eric Bower
date
2024-02-29 16:04:49 +0000 UTC
fix: empty username

refactor: ssh styles
feat: add pico+ info
22 files changed,  +443, -619
M go.mod
M go.sum
M db/db.go
+7, -6
 1@@ -1,6 +1,7 @@
 2 package db
 3 
 4 import (
 5+	"database/sql"
 6 	"database/sql/driver"
 7 	"encoding/json"
 8 	"errors"
 9@@ -255,19 +256,19 @@ var DenyList = []string{
10 }
11 
12 type DB interface {
13-	AddUser() (string, error)
14+	RegisterUser(name, pubkey string) (*User, error)
15 	RemoveUsers(userIDs []string) error
16-	LinkUserKey(userID string, key string) error
17-	FindPublicKeyForKey(key string) (*PublicKey, error)
18+	LinkUserKey(userID string, pubkey string, tx *sql.Tx) error
19+	FindPublicKeyForKey(pubkey string) (*PublicKey, error)
20 	FindKeysForUser(user *User) ([]*PublicKey, error)
21-	RemoveKeys(keyIDs []string) error
22+	RemoveKeys(pubkeyIDs []string) error
23 
24 	FindSiteAnalytics(space string) (*Analytics, error)
25 
26 	FindUsers() ([]*User, error)
27 	FindUserForName(name string) (*User, error)
28-	FindUserForNameAndKey(name string, key string) (*User, error)
29-	FindUserForKey(name string, key string) (*User, error)
30+	FindUserForNameAndKey(name string, pubkey string) (*User, error)
31+	FindUserForKey(name string, pubkey string) (*User, error)
32 	FindUser(userID string) (*User, error)
33 	ValidateName(name string) (bool, error)
34 	SetUserName(userID string, name string) error
M db/postgres/storage.go
+46, -9
 1@@ -234,7 +234,7 @@ const (
 2 		file_size, mime_type, shasum, data, expires_at, updated_at)
 3 	VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)
 4 	RETURNING id`
 5-	sqlInsertUser      = `INSERT INTO app_users DEFAULT VALUES returning id`
 6+	sqlInsertUser      = `INSERT INTO app_users (name) VALUES($1) returning id`
 7 	sqlInsertTag       = `INSERT INTO post_tags (post_id, name) VALUES($1, $2) RETURNING id;`
 8 	sqlInsertAliases   = `INSERT INTO post_aliases (post_id, slug) VALUES($1, $2) RETURNING id;`
 9 	sqlInsertFeedItems = `INSERT INTO feed_items (post_id, guid, data) VALUES ($1, $2, $3) RETURNING id;`
10@@ -363,13 +363,45 @@ func NewDB(databaseUrl string, logger *slog.Logger) *PsqlDB {
11 	return d
12 }
13 
14-func (me *PsqlDB) AddUser() (string, error) {
15+func (me *PsqlDB) RegisterUser(username string, pubkey string) (*db.User, error) {
16+	lowerName := strings.ToLower(username)
17+	valid, err := me.ValidateName(lowerName)
18+	if !valid {
19+		return nil, err
20+	}
21+
22+	ctx := context.Background()
23+	tx, err := me.Db.BeginTx(ctx, nil)
24+	if err != nil {
25+		return nil, err
26+	}
27+	defer func() {
28+		err = tx.Rollback()
29+	}()
30+
31+	stmt, err := tx.Prepare(sqlInsertUser)
32+	if err != nil {
33+		return nil, err
34+	}
35+	defer stmt.Close()
36+
37 	var id string
38-	err := me.Db.QueryRow(sqlInsertUser).Scan(&id)
39+	err = stmt.QueryRow(lowerName).Scan(&id)
40 	if err != nil {
41-		return "", err
42+		return nil, err
43 	}
44-	return id, nil
45+
46+	err = me.LinkUserKey(id, pubkey, tx)
47+	if err != nil {
48+		return nil, err
49+	}
50+
51+	err = tx.Commit()
52+	if err != nil {
53+		return nil, err
54+	}
55+
56+	return me.FindUserForKey(username, pubkey)
57 }
58 
59 func (me *PsqlDB) RemoveUsers(userIDs []string) error {
60@@ -378,12 +410,17 @@ func (me *PsqlDB) RemoveUsers(userIDs []string) error {
61 	return err
62 }
63 
64-func (me *PsqlDB) LinkUserKey(userID string, key string) error {
65+func (me *PsqlDB) LinkUserKey(userID string, key string, tx *sql.Tx) error {
66 	pk, _ := me.FindPublicKeyForKey(key)
67 	if pk != nil {
68 		return db.ErrPublicKeyTaken
69 	}
70-	_, err := me.Db.Exec(sqlInsertPublicKey, userID, key)
71+	var err error
72+	if tx != nil {
73+		_, err = tx.Exec(sqlInsertPublicKey, userID, key)
74+	} else {
75+		_, err = me.Db.Exec(sqlInsertPublicKey, userID, key)
76+	}
77 	return err
78 }
79 
80@@ -556,7 +593,7 @@ func (me *PsqlDB) FindUser(userID string) (*db.User, error) {
81 func (me *PsqlDB) ValidateName(name string) (bool, error) {
82 	lower := strings.ToLower(name)
83 	if slices.Contains(db.DenyList, lower) {
84-		return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameDenied)
85+		return false, fmt.Errorf("%s is on deny list: %w", lower, db.ErrNameDenied)
86 	}
87 	v := db.NameValidator.MatchString(lower)
88 	if !v {
89@@ -566,7 +603,7 @@ func (me *PsqlDB) ValidateName(name string) (bool, error) {
90 	if user == nil {
91 		return true, nil
92 	}
93-	return false, fmt.Errorf("%s is invalid: %w", lower, db.ErrNameTaken)
94+	return false, fmt.Errorf("%s already taken: %w", lower, db.ErrNameTaken)
95 }
96 
97 func (me *PsqlDB) FindUserForName(name string) (*db.User, error) {
M go.mod
+2, -0
 1@@ -20,6 +20,7 @@ require (
 2 	github.com/charmbracelet/wish v1.3.0
 3 	github.com/google/go-cmp v0.6.0
 4 	github.com/gorilla/feeds v1.1.2
 5+	github.com/kr/pty v1.1.8
 6 	github.com/lib/pq v1.10.9
 7 	github.com/microcosm-cc/bluemonday v1.0.26
 8 	github.com/minio/minio-go/v7 v7.0.63
 9@@ -54,6 +55,7 @@ require (
10 	github.com/charmbracelet/keygen v0.5.0 // indirect
11 	github.com/charmbracelet/log v0.3.1 // indirect
12 	github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
13+	github.com/creack/pty v1.1.21 // indirect
14 	github.com/dlclark/regexp2 v1.10.0 // indirect
15 	github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
16 	github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
M go.sum
+5, -0
 1@@ -41,6 +41,9 @@ github.com/charmbracelet/wish v1.2.0 h1:h5Wj9pr97IQz/l4gM5Xep2lXcY/YM+6O2RC2o3x0
 2 github.com/charmbracelet/wish v1.2.0/go.mod h1:JX3fC+178xadJYAhPu6qWtVDpJTwpnFvpdjz9RKJlUE=
 3 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
 4 github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
 5+github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 6+github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
 7+github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 9 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
10 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
11@@ -122,6 +125,8 @@ github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8=
12 github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
13 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
14 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
15+github.com/kr/pty v1.1.8 h1:AkaSdXYQOWeaO3neb8EM634ahkXXe3jYbVh/F9lq+GI=
16+github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
17 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
18 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
19 github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
M pgs/cli.go
+23, -25
  1@@ -17,24 +17,21 @@ import (
  2 	"github.com/picosh/pico/wish/cms/ui/common"
  3 )
  4 
  5-var re = lipgloss.NewRenderer(os.Stdout)
  6-var baseStyle = re.NewStyle().Padding(0, 1)
  7-var headerStyle = baseStyle.Copy().Foreground(common.Indigo).Bold(true)
  8-var borderStyle = re.NewStyle().Foreground(lipgloss.Color("238"))
  9-
 10-func styleRows(row, col int) lipgloss.Style {
 11-	if row == 0 {
 12-		return headerStyle
 13-	}
 14+func styleRows(styles common.Styles) func(row, col int) lipgloss.Style {
 15+	return func(row, col int) lipgloss.Style {
 16+		if row == 0 {
 17+			return styles.CliHeader
 18+		}
 19 
 20-	even := row%2 == 0
 21-	if even {
 22-		return baseStyle.Copy().Foreground(lipgloss.Color("245"))
 23+		even := row%2 == 0
 24+		if even {
 25+			return styles.CliPadding.Copy().Foreground(lipgloss.Color("245"))
 26+		}
 27+		return styles.CliPadding.Copy().Foreground(lipgloss.Color("252"))
 28 	}
 29-	return baseStyle.Copy().Foreground(lipgloss.Color("252"))
 30 }
 31 
 32-func projectTable(projects []*db.Project) *table.Table {
 33+func projectTable(styles common.Styles, projects []*db.Project) *table.Table {
 34 	headers := []string{
 35 		"Name",
 36 		"Last Updated",
 37@@ -62,16 +59,16 @@ func projectTable(projects []*db.Project) *table.Table {
 38 
 39 	t := table.New().
 40 		Border(lipgloss.NormalBorder()).
 41-		BorderStyle(borderStyle).
 42+		BorderStyle(styles.CliBorder).
 43 		Headers(headers...).
 44 		Rows(data...).
 45-		StyleFunc(styleRows)
 46+		StyleFunc(styleRows(styles))
 47 	return t
 48 }
 49 
 50-func getHelpText(userName string) string {
 51+func getHelpText(styles common.Styles, userName string) string {
 52 	helpStr := "Commands: [help, stats, ls, rm, link, unlink, prune, retain, depends, acl]\n\n"
 53-	helpStr += "NOTICE: *must* append with `--write` for the changes to persist.\n\n"
 54+	helpStr += styles.Note.Render("NOTICE:") + " *must* append with `--write` for the changes to persist.\n\n"
 55 
 56 	projectName := "projA"
 57 	headers := []string{"Cmd", "Description"}
 58@@ -120,10 +117,10 @@ func getHelpText(userName string) string {
 59 
 60 	t := table.New().
 61 		Border(lipgloss.NormalBorder()).
 62-		BorderStyle(borderStyle).
 63+		BorderStyle(styles.CliBorder).
 64 		Headers(headers...).
 65 		Rows(data...).
 66-		StyleFunc(styleRows)
 67+		StyleFunc(styleRows(styles))
 68 
 69 	helpStr += t.String()
 70 	return helpStr
 71@@ -165,6 +162,7 @@ type Cmd struct {
 72 	Store   storage.StorageServe
 73 	Dbpool  db.DB
 74 	Write   bool
 75+	Styles  common.Styles
 76 }
 77 
 78 func (c *Cmd) output(out string) {
 79@@ -236,7 +234,7 @@ func (c *Cmd) RmProjectAssets(projectName string) error {
 80 }
 81 
 82 func (c *Cmd) help() {
 83-	c.output(getHelpText(c.User.Name))
 84+	c.output(getHelpText(c.Styles, c.User.Name))
 85 }
 86 
 87 func (c *Cmd) stats(cfgMaxSize uint64) error {
 88@@ -274,10 +272,10 @@ func (c *Cmd) stats(cfgMaxSize uint64) error {
 89 
 90 	t := table.New().
 91 		Border(lipgloss.NormalBorder()).
 92-		BorderStyle(borderStyle).
 93+		BorderStyle(c.Styles.CliBorder).
 94 		Headers(headers...).
 95 		Rows(data).
 96-		StyleFunc(styleRows)
 97+		StyleFunc(styleRows(c.Styles))
 98 	c.output(t.String())
 99 
100 	return nil
101@@ -293,7 +291,7 @@ func (c *Cmd) ls() error {
102 		c.output("no projects found")
103 	}
104 
105-	t := projectTable(projects)
106+	t := projectTable(c.Styles, projects)
107 	c.output(t.String())
108 
109 	return nil
110@@ -379,7 +377,7 @@ func (c *Cmd) depends(projectName string) error {
111 		return nil
112 	}
113 
114-	t := projectTable(projects)
115+	t := projectTable(c.Styles, projects)
116 	c.output(t.String())
117 
118 	return nil
M pgs/cms.go
+57, -49
  1@@ -22,7 +22,6 @@ import (
  2 	"github.com/picosh/pico/wish/cms/ui/info"
  3 	"github.com/picosh/pico/wish/cms/ui/keys"
  4 	"github.com/picosh/pico/wish/cms/ui/tokens"
  5-	"github.com/picosh/pico/wish/cms/ui/username"
  6 )
  7 
  8 type status int
  9@@ -64,15 +63,10 @@ var menuChoices = map[menuChoice]string{
 10 	exitChoice:   "Exit",
 11 }
 12 
 13-var (
 14-	spinnerStyle = lipgloss.NewStyle().
 15-		Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
 16-)
 17-
 18-func NewSpinner() spinner.Model {
 19+func NewSpinner(styles common.Styles) spinner.Model {
 20 	s := spinner.New()
 21 	s.Spinner = spinner.Dot
 22-	s.Style = spinnerStyle
 23+	s.Style = styles.Spinner
 24 	return s
 25 }
 26 
 27@@ -90,6 +84,7 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 28 		key, err := shared.KeyText(s)
 29 		if err != nil {
 30 			logger.Error(err.Error())
 31+			return nil, nil
 32 		}
 33 
 34 		sshUser := s.User()
 35@@ -107,6 +102,10 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 36 			logger.Error(err.Error())
 37 		}
 38 
 39+		renderer := lipgloss.NewRenderer(s)
 40+		renderer.SetOutput(common.OutputFromSession(s))
 41+		styles := common.DefaultStyles(renderer)
 42+
 43 		m := model{
 44 			cfg:        cfg,
 45 			urls:       urls,
 46@@ -116,8 +115,8 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 47 			sshUser:    sshUser,
 48 			status:     statusInit,
 49 			menuChoice: unsetChoice,
 50-			styles:     common.DefaultStyles(),
 51-			spinner:    common.NewSpinner(),
 52+			styles:     styles,
 53+			spinner:    common.NewSpinner(styles),
 54 			terminalSize: tea.WindowSizeMsg{
 55 				Width:  80,
 56 				Height: 24,
 57@@ -131,31 +130,34 @@ func CmsMiddleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 58 		}
 59 		m.user = user
 60 
 61+		ff, _ := m.findPlusFeatureFlag()
 62+		m.plusFeatureFlag = ff
 63+
 64 		return m, []tea.ProgramOption{tea.WithAltScreen()}
 65 	}
 66 }
 67 
 68 // Just a generic tea.Model to demo terminal information of ssh.
 69 type model struct {
 70-	cfg           *config.ConfigCms
 71-	urls          config.ConfigURL
 72-	publicKey     string
 73-	dbpool        db.DB
 74-	st            storage.StorageServe
 75-	user          *db.User
 76-	err           error
 77-	sshUser       string
 78-	status        status
 79-	menuIndex     int
 80-	menuChoice    menuChoice
 81-	styles        common.Styles
 82-	info          info.Model
 83-	spinner       spinner.Model
 84-	username      username.Model
 85-	keys          keys.Model
 86-	tokens        tokens.Model
 87-	createAccount account.CreateModel
 88-	terminalSize  tea.WindowSizeMsg
 89+	cfg             *config.ConfigCms
 90+	urls            config.ConfigURL
 91+	publicKey       string
 92+	dbpool          db.DB
 93+	st              storage.StorageServe
 94+	user            *db.User
 95+	plusFeatureFlag *db.FeatureFlag
 96+	err             error
 97+	sshUser         string
 98+	status          status
 99+	menuIndex       int
100+	menuChoice      menuChoice
101+	styles          common.Styles
102+	info            info.Model
103+	spinner         spinner.Model
104+	keys            keys.Model
105+	tokens          tokens.Model
106+	createAccount   account.CreateModel
107+	terminalSize    tea.WindowSizeMsg
108 }
109 
110 func (m model) Init() tea.Cmd {
111@@ -174,7 +176,7 @@ func (m model) findUser() (*db.User, error) {
112 	user, err := m.dbpool.FindUserForKey(m.sshUser, m.publicKey)
113 
114 	if err != nil {
115-		logger.Error(err.Error())
116+		logger.Error("no user found for public key", "err", err.Error())
117 		// we only want to throw an error for specific cases
118 		if errors.Is(err, &db.ErrMultiplePublicKeys{}) {
119 			return nil, err
120@@ -185,6 +187,19 @@ func (m model) findUser() (*db.User, error) {
121 	return user, nil
122 }
123 
124+func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
125+	if m.user == nil {
126+		return nil, nil
127+	}
128+
129+	ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "pgs")
130+	if err != nil {
131+		return nil, err
132+	}
133+
134+	return ff, nil
135+}
136+
137 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
138 	var (
139 		cmds []tea.Cmd
140@@ -228,29 +243,22 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
141 				}
142 			}
143 		}
144-	case username.NameSetMsg:
145-		m.status = statusReady
146-		m.info.User.Name = string(msg)
147-		m.user = m.info.User
148-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
149 	case account.CreateAccountMsg:
150 		m.status = statusReady
151 		m.info.User = msg
152 		m.user = msg
153-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
154-		m.info = info.NewModel(m.cfg, m.urls, m.user)
155-		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
156-		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
157-		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
158+		m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
159+		m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
160+		m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
161+		m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
162 	}
163 
164 	switch m.status {
165 	case statusInit:
166-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
167-		m.info = info.NewModel(m.cfg, m.urls, m.user)
168-		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
169-		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
170-		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
171+		m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
172+		m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
173+		m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
174+		m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
175 		if m.user == nil {
176 			m.status = statusNoAccount
177 		} else {
178@@ -280,7 +288,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
179 		cmd = newCmd
180 
181 		if m.keys.Exit {
182-			m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
183+			m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
184 			m.status = statusReady
185 		} else if m.keys.Quit {
186 			m.status = statusQuitting
187@@ -296,7 +304,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
188 		cmd = newCmd
189 
190 		if m.tokens.Exit {
191-			m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
192+			m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
193 			m.status = statusReady
194 		} else if m.tokens.Quit {
195 			m.status = statusQuitting
196@@ -305,7 +313,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
197 	case statusNoAccount:
198 		m.createAccount, cmd = account.Update(msg, m.createAccount)
199 		if m.createAccount.Done {
200-			m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
201+			m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey) // reset the state
202 			m.status = statusReady
203 		} else if m.createAccount.Quit {
204 			m.status = statusQuitting
205@@ -356,7 +364,7 @@ func footerView(m model) string {
206 	if m.err != nil {
207 		return m.errorView(m.err)
208 	}
209-	return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
210+	return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
211 }
212 
213 func (m model) errorView(err error) string {
M pgs/config.go
+1, -1
1@@ -42,7 +42,7 @@ func NewConfigSite() *shared.ConfigSite {
2 			MinioURL:    minioURL,
3 			MinioUser:   minioUser,
4 			MinioPass:   minioPass,
5-			Description: "hacker labs",
6+			Description: "A zero-install static site hosting service for hackers",
7 			IntroText:   intro,
8 			Space:       "pgs",
9 			// IMPORTANT: make sure `shared.GetMimeType` has the extensions being
M pgs/wish.go
+8, -0
 1@@ -6,11 +6,13 @@ import (
 2 	"slices"
 3 	"strings"
 4 
 5+	"github.com/charmbracelet/lipgloss"
 6 	"github.com/charmbracelet/ssh"
 7 	"github.com/charmbracelet/wish"
 8 	"github.com/picosh/pico/db"
 9 	uploadassets "github.com/picosh/pico/filehandlers/assets"
10 	"github.com/picosh/pico/shared"
11+	"github.com/picosh/pico/wish/cms/ui/common"
12 	"github.com/picosh/send/send/utils"
13 )
14 
15@@ -83,6 +85,11 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
16 
17 			args := sesh.Command()
18 
19+			renderer := lipgloss.NewRenderer(sesh)
20+			// this might be dangerous but going with it for now
21+			// renderer.SetColorProfile(termenv.ANSI256)
22+			styles := common.DefaultStyles(renderer)
23+
24 			opts := Cmd{
25 				Session: sesh,
26 				User:    user,
27@@ -90,6 +97,7 @@ func WishMiddleware(handler *uploadassets.UploadAssetHandler) wish.Middleware {
28 				Log:     log,
29 				Dbpool:  dbpool,
30 				Write:   false,
31+				Styles:  styles,
32 			}
33 
34 			cmd := strings.TrimSpace(args[0])
M wish/cms/cms.go
+61, -53
  1@@ -24,7 +24,6 @@ import (
  2 	"github.com/picosh/pico/wish/cms/ui/keys"
  3 	"github.com/picosh/pico/wish/cms/ui/posts"
  4 	"github.com/picosh/pico/wish/cms/ui/tokens"
  5-	"github.com/picosh/pico/wish/cms/ui/username"
  6 )
  7 
  8 type status int
  9@@ -70,15 +69,10 @@ var menuChoices = map[menuChoice]string{
 10 	exitChoice:   "Exit",
 11 }
 12 
 13-var (
 14-	spinnerStyle = lipgloss.NewStyle().
 15-		Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
 16-)
 17-
 18-func NewSpinner() spinner.Model {
 19+func NewSpinner(styles common.Styles) spinner.Model {
 20 	s := spinner.New()
 21 	s.Spinner = spinner.Dot
 22-	s.Style = spinnerStyle
 23+	s.Style = styles.Spinner
 24 	return s
 25 }
 26 
 27@@ -113,6 +107,10 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 28 			logger.Error(err.Error())
 29 		}
 30 
 31+		renderer := lipgloss.NewRenderer(s)
 32+		renderer.SetOutput(common.OutputFromSession(s))
 33+		styles := common.DefaultStyles(renderer)
 34+
 35 		m := model{
 36 			cfg:        cfg,
 37 			urls:       urls,
 38@@ -122,8 +120,8 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 39 			sshUser:    sshUser,
 40 			status:     statusInit,
 41 			menuChoice: unsetChoice,
 42-			styles:     common.DefaultStyles(),
 43-			spinner:    common.NewSpinner(),
 44+			styles:     styles,
 45+			spinner:    common.NewSpinner(styles),
 46 			terminalSize: tea.WindowSizeMsg{
 47 				Width:  80,
 48 				Height: 24,
 49@@ -137,32 +135,35 @@ func Middleware(cfg *config.ConfigCms, urls config.ConfigURL) bm.Handler {
 50 		}
 51 		m.user = user
 52 
 53+		ff, _ := m.findPlusFeatureFlag()
 54+		m.plusFeatureFlag = ff
 55+
 56 		return m, []tea.ProgramOption{tea.WithAltScreen()}
 57 	}
 58 }
 59 
 60 // Just a generic tea.Model to demo terminal information of ssh.
 61 type model struct {
 62-	cfg           *config.ConfigCms
 63-	urls          config.ConfigURL
 64-	publicKey     string
 65-	dbpool        db.DB
 66-	st            storage.StorageServe
 67-	user          *db.User
 68-	err           error
 69-	sshUser       string
 70-	status        status
 71-	menuIndex     int
 72-	menuChoice    menuChoice
 73-	terminalSize  tea.WindowSizeMsg
 74-	styles        common.Styles
 75-	info          info.Model
 76-	spinner       spinner.Model
 77-	username      username.Model
 78-	posts         posts.Model
 79-	keys          keys.Model
 80-	tokens        tokens.Model
 81-	createAccount account.CreateModel
 82+	cfg             *config.ConfigCms
 83+	urls            config.ConfigURL
 84+	publicKey       string
 85+	dbpool          db.DB
 86+	st              storage.StorageServe
 87+	user            *db.User
 88+	plusFeatureFlag *db.FeatureFlag
 89+	err             error
 90+	sshUser         string
 91+	status          status
 92+	menuIndex       int
 93+	menuChoice      menuChoice
 94+	terminalSize    tea.WindowSizeMsg
 95+	styles          common.Styles
 96+	info            info.Model
 97+	spinner         spinner.Model
 98+	posts           posts.Model
 99+	keys            keys.Model
100+	tokens          tokens.Model
101+	createAccount   account.CreateModel
102 }
103 
104 func (m model) Init() tea.Cmd {
105@@ -192,6 +193,19 @@ func (m model) findUser() (*db.User, error) {
106 	return user, nil
107 }
108 
109+func (m model) findPlusFeatureFlag() (*db.FeatureFlag, error) {
110+	if m.user == nil {
111+		return nil, nil
112+	}
113+
114+	ff, err := m.dbpool.FindFeatureForUser(m.user.ID, "pgs")
115+	if err != nil {
116+		return nil, err
117+	}
118+
119+	return ff, nil
120+}
121+
122 func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
123 	var (
124 		cmds []tea.Cmd
125@@ -235,32 +249,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
126 				}
127 			}
128 		}
129-	case username.NameSetMsg:
130-		m.status = statusReady
131-		m.info.User.Name = string(msg)
132-		m.user = m.info.User
133-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser) // reset the state
134+
135 	case account.CreateAccountMsg:
136 		m.status = statusReady
137 		m.info.User = msg
138 		m.user = msg
139-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
140-		m.info = info.NewModel(m.cfg, m.urls, m.user)
141-		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
142-		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
143-		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
144+		m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
145+		m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
146+		m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
147+		m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
148 
149 		perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
150-		m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
151+		m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
152 	}
153 
154 	switch m.status {
155 	case statusInit:
156-		m.username = username.NewModel(m.dbpool, m.user, m.sshUser)
157-		m.info = info.NewModel(m.cfg, m.urls, m.user)
158-		m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
159-		m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
160-		m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey)
161+		m.info = info.NewModel(m.styles, m.cfg, m.urls, m.user, m.plusFeatureFlag)
162+		m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
163+		m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
164+		m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey)
165 		if m.user == nil {
166 			m.status = statusNoAccount
167 		} else {
168@@ -268,7 +276,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
169 		}
170 
171 		perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
172-		m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
173+		m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
174 	}
175 
176 	m, cmd = updateChildren(msg, m)
177@@ -294,9 +302,9 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
178 
179 		if m.posts.Exit {
180 			perPage := math.Floor(float64(m.terminalSize.Height) / 10.0)
181-			m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
182+			m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
183 
184-			m.posts = posts.NewModel(m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
185+			m.posts = posts.NewModel(m.styles, m.cfg, m.urls, m.dbpool, m.user, m.st, int(perPage))
186 			m.status = statusReady
187 		} else if m.posts.Quit {
188 			m.status = statusQuitting
189@@ -312,7 +320,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
190 		cmd = newCmd
191 
192 		if m.keys.Exit {
193-			m.keys = keys.NewModel(m.cfg, m.dbpool, m.user)
194+			m.keys = keys.NewModel(m.styles, m.cfg, m.dbpool, m.user)
195 			m.status = statusReady
196 		} else if m.keys.Quit {
197 			m.status = statusQuitting
198@@ -328,7 +336,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
199 		cmd = newCmd
200 
201 		if m.tokens.Exit {
202-			m.tokens = tokens.NewModel(m.cfg, m.dbpool, m.user)
203+			m.tokens = tokens.NewModel(m.styles, m.cfg, m.dbpool, m.user)
204 			m.status = statusReady
205 		} else if m.tokens.Quit {
206 			m.status = statusQuitting
207@@ -337,7 +345,7 @@ func updateChildren(msg tea.Msg, m model) (model, tea.Cmd) {
208 	case statusNoAccount:
209 		m.createAccount, cmd = account.Update(msg, m.createAccount)
210 		if m.createAccount.Done {
211-			m.createAccount = account.NewCreateModel(m.cfg, m.dbpool, m.publicKey) // reset the state
212+			m.createAccount = account.NewCreateModel(m.styles, m.cfg, m.dbpool, m.publicKey) // reset the state
213 			m.status = statusReady
214 		} else if m.createAccount.Quit {
215 			m.status = statusQuitting
216@@ -392,7 +400,7 @@ func footerView(m model) string {
217 	if m.err != nil {
218 		return m.errorView(m.err)
219 	}
220-	return "\n\n" + common.HelpView("j/k, ↑/↓: choose", "enter: select")
221+	return "\n\n" + common.HelpView(m.styles, "j/k, ↑/↓: choose", "enter: select")
222 }
223 
224 func (m model) errorView(err error) string {
M wish/cms/ui/account/create.go
+24, -57
  1@@ -41,6 +41,9 @@ type errMsg struct{ err error }
  2 
  3 func (e errMsg) Error() string { return e.err.Error() }
  4 
  5+var deny = strings.Join(db.DenyList, ", ")
  6+var helpMsg = fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
  7+
  8 // Model holds the state of the username UI.
  9 type CreateModel struct {
 10 	Done bool // true when it's time to exit this view
 11@@ -91,13 +94,11 @@ func (m *CreateModel) indexBackward() {
 12 }
 13 
 14 // NewModel returns a new username model in its initial state.
 15-func NewCreateModel(cfg *config.ConfigCms, dbpool db.DB, publicKey string) CreateModel {
 16-	st := common.DefaultStyles()
 17-
 18+func NewCreateModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, publicKey string) CreateModel {
 19 	im := input.New()
 20-	im.Cursor.Style = st.Cursor
 21-	im.Placeholder = "erock"
 22-	im.Prompt = st.FocusedPrompt.String()
 23+	im.Cursor.Style = styles.Cursor
 24+	im.Placeholder = "enter username"
 25+	im.Prompt = styles.FocusedPrompt.String()
 26 	im.CharLimit = 50
 27 	im.Focus()
 28 
 29@@ -106,21 +107,21 @@ func NewCreateModel(cfg *config.ConfigCms, dbpool db.DB, publicKey string) Creat
 30 		Done:      false,
 31 		Quit:      false,
 32 		dbpool:    dbpool,
 33-		styles:    st,
 34+		styles:    styles,
 35 		state:     ready,
 36 		newName:   "",
 37 		index:     textInput,
 38 		errMsg:    "",
 39 		input:     im,
 40-		spinner:   common.NewSpinner(),
 41+		spinner:   common.NewSpinner(styles),
 42 		publicKey: publicKey,
 43 	}
 44 }
 45 
 46 // Init is the Bubble Tea initialization function.
 47-func Init(cfg *config.ConfigCms, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
 48+func Init(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, publicKey string) func() (CreateModel, tea.Cmd) {
 49 	return func() (CreateModel, tea.Cmd) {
 50-		m := NewCreateModel(cfg, dbpool, publicKey)
 51+		m := NewCreateModel(styles, cfg, dbpool, publicKey)
 52 		return m, InitialCmd()
 53 	}
 54 }
 55@@ -207,8 +208,6 @@ func Update(msg tea.Msg, m CreateModel) (CreateModel, tea.Cmd) {
 56 	case NameInvalidMsg:
 57 		m.state = ready
 58 		head := m.styles.Error.Render("Invalid name. ")
 59-		deny := strings.Join(db.DenyList, ", ")
 60-		helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
 61 		body := m.styles.Subtle.Render(helpMsg)
 62 		m.errMsg = m.styles.Wrap.Render(head + body)
 63 
 64@@ -242,19 +241,20 @@ func View(m CreateModel) string {
 65 		return "Registration is closed for this service.  Press 'esc' to exit."
 66 	}
 67 
 68-	s := fmt.Sprintf("%s\n\n%s\n\n", m.cfg.Description, m.cfg.IntroText)
 69-	s += "Enter a username\n\n"
 70+	s := fmt.Sprintf("%s\n\n%s\n", "hacker labs", m.cfg.IntroText)
 71+	s += fmt.Sprintf("Public Key: %s\n\n", m.publicKey)
 72 	s += m.input.View() + "\n\n"
 73 
 74 	if m.state == submitting {
 75 		s += spinnerView(m)
 76 	} else {
 77-		s += common.OKButtonView(m.index == 1, true)
 78-		s += " " + common.CancelButtonView(m.index == 2, false)
 79+		s += common.OKButtonView(m.styles, m.index == 1, true)
 80+		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
 81 		if m.errMsg != "" {
 82 			s += "\n\n" + m.errMsg
 83 		}
 84 	}
 85+	s += fmt.Sprintf("\n\n%s\n", helpMsg)
 86 
 87 	return s
 88 }
 89@@ -263,59 +263,26 @@ func spinnerView(m CreateModel) string {
 90 	return m.spinner.View() + " Creating account..."
 91 }
 92 
 93-func registerUser(m CreateModel) (*db.User, error) {
 94-	userID, err := m.dbpool.AddUser()
 95-	if err != nil {
 96-		return nil, err
 97-	}
 98-
 99-	err = m.dbpool.LinkUserKey(userID, m.publicKey)
100-	if err != nil {
101-		return nil, err
102-	}
103-
104-	user, err := m.dbpool.FindUser(userID)
105-	if err != nil {
106-		return nil, err
107-	}
108-
109-	return user, nil
110-
111-}
112-
113-// Attempt to update the username on the server.
114 func createAccount(m CreateModel) tea.Cmd {
115 	return func() tea.Msg {
116 		if m.newName == "" {
117 			return NameInvalidMsg{}
118 		}
119 
120-		valid, err := m.dbpool.ValidateName(m.newName)
121-		// Validate before resetting the session to potentially save some
122-		// network traffic and keep things feeling speedy.
123-		if !valid {
124+		user, err := m.dbpool.RegisterUser(m.newName, m.publicKey)
125+		fmt.Println(err)
126+		if err != nil {
127 			if errors.Is(err, db.ErrNameTaken) {
128 				return NameTakenMsg{}
129-			} else {
130+			} else if errors.Is(err, db.ErrNameInvalid) {
131+				return NameInvalidMsg{}
132+			} else if errors.Is(err, db.ErrNameDenied) {
133 				return NameInvalidMsg{}
134+			} else {
135+				return errMsg{err}
136 			}
137 		}
138 
139-		user, err := registerUser(m)
140-		if err != nil {
141-			return errMsg{err}
142-		}
143-
144-		err = m.dbpool.SetUserName(user.ID, m.newName)
145-		if err != nil {
146-			return errMsg{err}
147-		}
148-
149-		user, err = m.dbpool.FindUserForKey(m.newName, m.publicKey)
150-		if err != nil {
151-			return errMsg{err}
152-		}
153-
154 		return CreateAccountMsg(user)
155 	}
156 }
M wish/cms/ui/common/styles.go
+113, -22
  1@@ -1,9 +1,72 @@
  2 package common
  3 
  4 import (
  5+	"fmt"
  6+	"log"
  7+	"os"
  8+	"strings"
  9+
 10 	"github.com/charmbracelet/lipgloss"
 11+	"github.com/charmbracelet/ssh"
 12+	"github.com/kr/pty"
 13+	"github.com/muesli/termenv"
 14 )
 15 
 16+// Bridge Wish and Termenv so we can query for a user's terminal capabilities.
 17+type sshOutput struct {
 18+	ssh.Session
 19+	tty *os.File
 20+}
 21+
 22+func (s *sshOutput) Write(p []byte) (int, error) {
 23+	return s.Session.Write(p)
 24+}
 25+
 26+func (s *sshOutput) Read(p []byte) (int, error) {
 27+	return s.Session.Read(p)
 28+}
 29+
 30+func (s *sshOutput) Fd() uintptr {
 31+	return s.tty.Fd()
 32+}
 33+
 34+type sshEnviron struct {
 35+	environ []string
 36+}
 37+
 38+func (s *sshEnviron) Getenv(key string) string {
 39+	for _, v := range s.environ {
 40+		if strings.HasPrefix(v, key+"=") {
 41+			return v[len(key)+1:]
 42+		}
 43+	}
 44+	return ""
 45+}
 46+
 47+func (s *sshEnviron) Environ() []string {
 48+	return s.environ
 49+}
 50+
 51+// Create a termenv.Output from the session.
 52+func OutputFromSession(sess ssh.Session) *termenv.Output {
 53+	sshPty, _, _ := sess.Pty()
 54+	_, tty, err := pty.Open()
 55+	if err != nil {
 56+		// TODO: FIX
 57+		log.Fatal(err)
 58+	}
 59+	o := &sshOutput{
 60+		Session: sess,
 61+		tty:     tty,
 62+	}
 63+	environ := sess.Environ()
 64+	environ = append(environ, fmt.Sprintf("TERM=%s", sshPty.Term))
 65+	e := &sshEnviron{environ: environ}
 66+	// We need to use unsafe mode here because the ssh session is not running
 67+	// locally and we already know that the session is a TTY.
 68+	return termenv.NewOutput(o, termenv.WithUnsafe(), termenv.WithEnvironment(e))
 69+}
 70+
 71 // Color definitions.
 72 var (
 73 	Indigo       = lipgloss.AdaptiveColor{Light: "#5A56E0", Dark: "#7571F9"}
 74@@ -38,49 +101,77 @@ type Styles struct {
 75 	SelectedMenuItem,
 76 	Checkmark,
 77 	Logo,
 78+	BlurredButtonStyle,
 79+	FocusedButtonStyle,
 80+	HelpSection,
 81+	HelpDivider,
 82+	Spinner,
 83+	CliPadding,
 84+	CliBorder,
 85+	CliHeader,
 86 	App lipgloss.Style
 87+	Renderer *lipgloss.Renderer
 88 }
 89 
 90-func DefaultStyles() Styles {
 91-	s := Styles{}
 92+func DefaultStyles(renderer *lipgloss.Renderer) Styles {
 93+	s := Styles{
 94+		Renderer: renderer,
 95+	}
 96 
 97-	s.Cursor = lipgloss.NewStyle().Foreground(Fuschia)
 98-	s.Wrap = lipgloss.NewStyle().Width(58)
 99-	s.Keyword = lipgloss.NewStyle().Foreground(Green)
100+	s.Cursor = renderer.NewStyle().Foreground(Fuschia)
101+	s.Wrap = renderer.NewStyle().Width(58)
102+	s.Keyword = renderer.NewStyle().Foreground(Green)
103 	s.Paragraph = s.Wrap.Copy().Margin(1, 0, 0, 2)
104-	s.Code = lipgloss.NewStyle().
105+	s.Code = renderer.NewStyle().
106 		Foreground(lipgloss.AdaptiveColor{Light: "#FF4672", Dark: "#ED567A"}).
107 		Background(lipgloss.AdaptiveColor{Light: "#EBE5EC", Dark: "#2B2A2A"}).
108 		Padding(0, 1)
109-	s.Subtle = lipgloss.NewStyle().
110+	s.Subtle = renderer.NewStyle().
111 		Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
112-	s.Error = lipgloss.NewStyle().Foreground(Red)
113-	s.Prompt = lipgloss.NewStyle().MarginRight(1).SetString(">")
114+	s.Error = renderer.NewStyle().Foreground(Red)
115+	s.Prompt = renderer.NewStyle().MarginRight(1).SetString(">")
116 	s.FocusedPrompt = s.Prompt.Copy().Foreground(Fuschia)
117-	s.Note = lipgloss.NewStyle().Foreground(Green)
118-	s.NoteDim = lipgloss.NewStyle().
119+	s.Note = renderer.NewStyle().Foreground(Green)
120+	s.NoteDim = renderer.NewStyle().
121 		Foreground(lipgloss.AdaptiveColor{Light: "#ABE5D1", Dark: "#2B4A3F"})
122 	s.Delete = s.Error.Copy()
123-	s.DeleteDim = lipgloss.NewStyle().Foreground(FaintRed)
124-	s.Label = lipgloss.NewStyle().Foreground(Fuschia)
125-	s.LabelDim = lipgloss.NewStyle().Foreground(Indigo)
126-	s.ListKey = lipgloss.NewStyle().Foreground(Indigo)
127-	s.ListDim = lipgloss.NewStyle().Foreground(SubtleIndigo)
128-	s.InactivePagination = lipgloss.NewStyle().
129+	s.DeleteDim = renderer.NewStyle().Foreground(FaintRed)
130+	s.Label = renderer.NewStyle().Foreground(Fuschia)
131+	s.LabelDim = renderer.NewStyle().Foreground(Indigo)
132+	s.ListKey = renderer.NewStyle().Foreground(Indigo)
133+	s.ListDim = renderer.NewStyle().Foreground(SubtleIndigo)
134+	s.InactivePagination = renderer.NewStyle().
135 		Foreground(lipgloss.AdaptiveColor{Light: "#CACACA", Dark: "#4F4F4F"})
136-	s.SelectionMarker = lipgloss.NewStyle().
137+	s.SelectionMarker = renderer.NewStyle().
138 		Foreground(Fuschia).
139 		PaddingRight(1).
140 		SetString(">")
141-	s.Checkmark = lipgloss.NewStyle().
142+	s.Checkmark = renderer.NewStyle().
143 		SetString("✔").
144 		Foreground(Green)
145-	s.SelectedMenuItem = lipgloss.NewStyle().Foreground(Fuschia)
146-	s.Logo = lipgloss.NewStyle().
147+	s.SelectedMenuItem = renderer.NewStyle().Foreground(Fuschia)
148+	s.Logo = renderer.NewStyle().
149 		Foreground(Cream).
150 		Background(lipgloss.Color("#5A56E0")).
151 		Padding(0, 1)
152-	s.App = lipgloss.NewStyle().Margin(1, 0, 1, 2)
153+	s.BlurredButtonStyle = renderer.NewStyle().
154+		Foreground(Cream).
155+		Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}).
156+		Padding(0, 3)
157+	s.FocusedButtonStyle = s.BlurredButtonStyle.Copy().
158+		Background(Fuschia)
159+	s.HelpDivider = renderer.NewStyle().
160+		Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}).
161+		Padding(0, 1).
162+		SetString("•")
163+	s.HelpSection = renderer.NewStyle().
164+		Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
165+	s.App = renderer.NewStyle().Margin(1, 0, 1, 2)
166+	s.Spinner = renderer.NewStyle().
167+		Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
168+	s.CliPadding = renderer.NewStyle().Padding(0, 1)
169+	s.CliHeader = s.CliPadding.Copy().Foreground(Indigo).Bold(true)
170+	s.CliBorder = renderer.NewStyle().Foreground(lipgloss.Color("238"))
171 
172 	return s
173 }
M wish/cms/ui/common/views.go
+19, -41
  1@@ -28,30 +28,15 @@ var lineColors = map[State]lipgloss.TerminalColor{
  2 }
  3 
  4 // VerticalLine return a vertical line colored according to the given state.
  5-func VerticalLine(state State) string {
  6-	return lipgloss.NewStyle().
  7+func VerticalLine(renderer *lipgloss.Renderer, state State) string {
  8+	return renderer.NewStyle().
  9 		SetString("│").
 10 		Foreground(lineColors[state]).
 11 		String()
 12 }
 13 
 14-var valStyle = lipgloss.NewStyle().Foreground(Indigo)
 15-
 16-var (
 17-	spinnerStyle = lipgloss.NewStyle().
 18-			Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
 19-
 20-	blurredButtonStyle = lipgloss.NewStyle().
 21-				Foreground(Cream).
 22-				Background(lipgloss.AdaptiveColor{Light: "#BDB0BE", Dark: "#827983"}).
 23-				Padding(0, 3)
 24-
 25-	focusedButtonStyle = blurredButtonStyle.Copy().
 26-				Background(Fuschia)
 27-)
 28-
 29 // KeyValueView renders key-value pairs.
 30-func KeyValueView(stuff ...string) string {
 31+func KeyValueView(styles Styles, stuff ...string) string {
 32 	if len(stuff) == 0 {
 33 		return ""
 34 	}
 35@@ -63,11 +48,11 @@ func KeyValueView(stuff ...string) string {
 36 	for i := 0; i < len(stuff); i++ {
 37 		if i%2 == 0 {
 38 			// even: key
 39-			s += fmt.Sprintf("%s %s: ", VerticalLine(StateNormal), stuff[i])
 40+			s += fmt.Sprintf("%s %s: ", VerticalLine(styles.Renderer, StateNormal), stuff[i])
 41 			continue
 42 		}
 43 		// odd: value
 44-		s += valStyle.Render(stuff[i])
 45+		s += styles.LabelDim.Render(stuff[i])
 46 		s += "\n"
 47 		index++
 48 	}
 49@@ -76,7 +61,10 @@ func KeyValueView(stuff ...string) string {
 50 }
 51 
 52 // NewSpinner returns a spinner model.
 53-func NewSpinner() spinner.Model {
 54+func NewSpinner(styles Styles) spinner.Model {
 55+	spinnerStyle := styles.Renderer.NewStyle().
 56+		Foreground(lipgloss.AdaptiveColor{Light: "#8E8E8E", Dark: "#747373"})
 57+
 58 	s := spinner.New()
 59 	s.Spinner = spinner.Dot
 60 	s.Style = spinnerStyle
 61@@ -84,21 +72,21 @@ func NewSpinner() spinner.Model {
 62 }
 63 
 64 // OKButtonView returns a button reading "OK".
 65-func OKButtonView(focused bool, defaultButton bool) string {
 66-	return styledButton("OK", defaultButton, focused)
 67+func OKButtonView(styles Styles, focused bool, defaultButton bool) string {
 68+	return styledButton(styles, "OK", defaultButton, focused)
 69 }
 70 
 71 // CancelButtonView returns a button reading "Cancel.".
 72-func CancelButtonView(focused bool, defaultButton bool) string {
 73-	return styledButton("Cancel", defaultButton, focused)
 74+func CancelButtonView(styles Styles, focused bool, defaultButton bool) string {
 75+	return styledButton(styles, "Cancel", defaultButton, focused)
 76 }
 77 
 78-func styledButton(str string, underlined, focused bool) string {
 79+func styledButton(styles Styles, str string, underlined, focused bool) string {
 80 	var st lipgloss.Style
 81 	if focused {
 82-		st = focusedButtonStyle.Copy()
 83+		st = styles.FocusedButtonStyle.Copy()
 84 	} else {
 85-		st = blurredButtonStyle.Copy()
 86+		st = styles.BlurredButtonStyle.Copy()
 87 	}
 88 	if underlined {
 89 		st = st.Underline(true)
 90@@ -106,28 +94,18 @@ func styledButton(str string, underlined, focused bool) string {
 91 	return st.Render(str)
 92 }
 93 
 94-var (
 95-	helpDivider = lipgloss.NewStyle().
 96-			Foreground(lipgloss.AdaptiveColor{Light: "#DDDADA", Dark: "#3C3C3C"}).
 97-			Padding(0, 1).
 98-			Render("•")
 99-
100-	helpSection = lipgloss.NewStyle().
101-			Foreground(lipgloss.AdaptiveColor{Light: "#9B9B9B", Dark: "#5C5C5C"})
102-)
103-
104 // HelpView renders text intended to display at help text, often at the
105 // bottom of a view.
106-func HelpView(sections ...string) string {
107+func HelpView(styles Styles, sections ...string) string {
108 	var s string
109 	if len(sections) == 0 {
110 		return s
111 	}
112 
113 	for i := 0; i < len(sections); i++ {
114-		s += helpSection.Render(sections[i])
115+		s += styles.HelpSection.Render(sections[i])
116 		if i < len(sections)-1 {
117-			s += helpDivider
118+			s += styles.HelpDivider.Render()
119 		}
120 	}
121 
M wish/cms/ui/createkey/create.go
+8, -10
 1@@ -87,13 +87,11 @@ func (m *Model) indexBackward() {
 2 }
 3 
 4 // NewModel returns a new username model in its initial state.
 5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 6-	st := common.DefaultStyles()
 7-
 8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 9 	im := input.New()
10-	im.Cursor.Style = st.Cursor
11+	im.Cursor.Style = styles.Cursor
12 	im.Placeholder = "ssh-ed25519 AAAA..."
13-	im.Prompt = st.FocusedPrompt.String()
14+	im.Prompt = styles.FocusedPrompt.String()
15 	im.CharLimit = 2049
16 	im.Focus()
17 
18@@ -102,13 +100,13 @@ func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
19 		Quit:    false,
20 		dbpool:  dbpool,
21 		user:    user,
22-		styles:  st,
23+		styles:  styles,
24 		state:   ready,
25 		newKey:  "",
26 		index:   textInput,
27 		errMsg:  "",
28 		input:   im,
29-		spinner: common.NewSpinner(),
30+		spinner: common.NewSpinner(styles),
31 	}
32 }
33 
34@@ -234,8 +232,8 @@ func (m Model) View() string {
35 	if m.state == submitting {
36 		s += spinnerView(m)
37 	} else {
38-		s += common.OKButtonView(m.index == 1, true)
39-		s += " " + common.CancelButtonView(m.index == 2, false)
40+		s += common.OKButtonView(m.styles, m.index == 1, true)
41+		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
42 		if m.errMsg != "" {
43 			s += "\n\n" + m.errMsg
44 		}
45@@ -279,7 +277,7 @@ func addPublicKey(m Model) tea.Cmd {
46 		}
47 
48 		key := sanitizeKey(m.newKey)
49-		err := m.dbpool.LinkUserKey(m.user.ID, key)
50+		err := m.dbpool.LinkUserKey(m.user.ID, key, nil)
51 		if err != nil {
52 			if errors.Is(err, db.ErrPublicKeyTaken) {
53 				return KeyTakenMsg{}
M wish/cms/ui/createtoken/create.go
+8, -10
 1@@ -89,13 +89,11 @@ func (m *Model) indexBackward() {
 2 }
 3 
 4 // NewModel returns a new username model in its initial state.
 5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 6-	st := common.DefaultStyles()
 7-
 8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 9 	im := input.New()
10-	im.Cursor.Style = st.Cursor
11+	im.Cursor.Style = styles.Cursor
12 	im.Placeholder = "A name used for your reference"
13-	im.Prompt = st.FocusedPrompt.String()
14+	im.Prompt = styles.FocusedPrompt.String()
15 	im.CharLimit = 256
16 	im.Focus()
17 
18@@ -104,14 +102,14 @@ func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
19 		Quit:      false,
20 		dbpool:    dbpool,
21 		user:      user,
22-		styles:    st,
23+		styles:    styles,
24 		state:     ready,
25 		tokenName: "",
26 		token:     "",
27 		index:     textInput,
28 		errMsg:    "",
29 		input:     im,
30-		spinner:   common.NewSpinner(),
31+		spinner:   common.NewSpinner(styles),
32 	}
33 }
34 
35@@ -232,10 +230,10 @@ func (m Model) View() string {
36 	} else if m.state == submitted {
37 		s = fmt.Sprintf("Save this token:\n%s\n\n", m.token)
38 		s += "After you exit this screen you will *not* be able to see it again.\n\n"
39-		s += common.OKButtonView(m.index == 1, true)
40+		s += common.OKButtonView(m.styles, m.index == 1, true)
41 	} else {
42-		s += common.OKButtonView(m.index == 1, true)
43-		s += " " + common.CancelButtonView(m.index == 2, false)
44+		s += common.OKButtonView(m.styles, m.index == 1, true)
45+		s += " " + common.CancelButtonView(m.styles, m.index == 2, false)
46 		if m.errMsg != "" {
47 			s += "\n\n" + m.errMsg
48 		}
M wish/cms/ui/info/info.go
+30, -16
 1@@ -18,22 +18,24 @@ func (e errMsg) Error() string {
 2 
 3 // Model stores the state of the info user interface.
 4 type Model struct {
 5-	cfg    *config.ConfigCms
 6-	urls   config.ConfigURL
 7-	Quit   bool // signals it's time to exit the whole application
 8-	Err    error
 9-	User   *db.User
10-	styles common.Styles
11+	cfg             *config.ConfigCms
12+	urls            config.ConfigURL
13+	Quit            bool // signals it's time to exit the whole application
14+	Err             error
15+	User            *db.User
16+	PlusFeatureFlag *db.FeatureFlag
17+	styles          common.Styles
18 }
19 
20 // NewModel returns a new Model in its initial state.
21-func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, user *db.User) Model {
22+func NewModel(styles common.Styles, cfg *config.ConfigCms, urls config.ConfigURL, user *db.User, ff *db.FeatureFlag) Model {
23 	return Model{
24-		Quit:   false,
25-		User:   user,
26-		styles: common.DefaultStyles(),
27-		cfg:    cfg,
28-		urls:   urls,
29+		Quit:            false,
30+		User:            user,
31+		styles:          styles,
32+		cfg:             cfg,
33+		urls:            urls,
34+		PlusFeatureFlag: ff,
35 	}
36 }
37 
38@@ -76,10 +78,22 @@ func (m Model) bioView() string {
39 		username = m.styles.Subtle.Render("(none set)")
40 	}
41 
42-	return common.KeyValueView(
43+	plus := "No"
44+	expires := ""
45+	if m.PlusFeatureFlag != nil {
46+		plus = "Yes"
47+		expires = m.PlusFeatureFlag.ExpiresAt.Format("02 Jan 2006")
48+	}
49+
50+	vals := []string{
51 		"Username", username,
52-		"URL", m.urls.BlogURL(username),
53-		"Public key", m.User.PublicKey.Key,
54 		"Joined", m.User.CreatedAt.Format("02 Jan 2006"),
55-	)
56+		"Pico+", plus,
57+	}
58+
59+	if expires != "" {
60+		vals = append(vals, "Pico+ Expires At", expires)
61+	}
62+
63+	return common.KeyValueView(m.styles, vals...)
64 }
M wish/cms/ui/keys/keys.go
+7, -9
 1@@ -82,26 +82,24 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
 2 }
 3 
 4 // NewModel creates a new model with defaults.
 5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 6-	st := common.DefaultStyles()
 7-
 8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 9 	p := pager.New()
10 	p.PerPage = keysPerPage
11 	p.Type = pager.Dots
12-	p.InactiveDot = st.InactivePagination.Render("•")
13+	p.InactiveDot = styles.InactivePagination.Render("•")
14 
15 	return Model{
16 		cfg:            cfg,
17 		dbpool:         dbpool,
18 		user:           user,
19-		styles:         st,
20+		styles:         styles,
21 		pager:          p,
22 		state:          stateLoading,
23 		err:            nil,
24 		activeKeyIndex: -1,
25 		keys:           []*db.PublicKey{},
26 		index:          0,
27-		spinner:        common.NewSpinner(),
28+		spinner:        common.NewSpinner(styles),
29 		Exit:           false,
30 		Quit:           false,
31 	}
32@@ -238,7 +236,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33 
34 	switch m.state {
35 	case stateNormal:
36-		m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user)
37+		m.createKey = createkey.NewModel(m.styles, m.cfg, m.dbpool, m.user)
38 	case stateDeletingKey:
39 		// If an item is being confirmed for delete, any key (other than the key
40 		// used for confirmation above) cancels the deletion
41@@ -271,7 +269,7 @@ func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
42 		m.createKey = createKeyModel
43 		cmd = newCmd
44 		if m.createKey.Done {
45-			m.createKey = createkey.NewModel(m.cfg, m.dbpool, m.user) // reset the state
46+			m.createKey = createkey.NewModel(m.styles, m.cfg, m.dbpool, m.user) // reset the state
47 			m.state = stateNormal
48 		} else if m.createKey.Quit {
49 			m.state = stateQuitting
50@@ -368,7 +366,7 @@ func helpView(m Model) string {
51 		items = append(items, "h/l, ←/→: page")
52 	}
53 	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
54-	return common.HelpView(items...)
55+	return common.HelpView(m.styles, items...)
56 }
57 
58 func (m Model) promptView(prompt string) string {
M wish/cms/ui/keys/keyview.go
+8, -8
 1@@ -9,8 +9,6 @@ import (
 2 	"golang.org/x/crypto/ssh"
 3 )
 4 
 5-var styles = common.DefaultStyles()
 6-
 7 func algo(keyType string) string {
 8 	if idx := strings.Index(keyType, "@"); idx > 0 {
 9 		return algo(keyType[0:idx])
10@@ -29,20 +27,21 @@ type Fingerprint struct {
11 	Type      string
12 	Value     string
13 	Algorithm string
14+	Styles    common.Styles
15 }
16 
17 // String outputs a string representation of the fingerprint.
18 func (f Fingerprint) String() string {
19 	return fmt.Sprintf(
20 		"%s %s",
21-		styles.ListDim.Render(strings.ToUpper(f.Algorithm)),
22-		styles.ListKey.Render(f.Type+":"+f.Value),
23+		f.Styles.ListDim.Render(strings.ToUpper(f.Algorithm)),
24+		f.Styles.ListKey.Render(f.Type+":"+f.Value),
25 	)
26 }
27 
28 // FingerprintSHA256 returns the algorithm and SHA256 fingerprint for the given
29 // key.
30-func FingerprintSHA256(k *db.PublicKey) (Fingerprint, error) {
31+func FingerprintSHA256(styles common.Styles, k *db.PublicKey) (Fingerprint, error) {
32 	key, _, _, _, err := ssh.ParseAuthorizedKey([]byte(k.Key))
33 	if err != nil {
34 		return Fingerprint{}, fmt.Errorf("failed to parse public key: %w", err)
35@@ -52,6 +51,7 @@ func FingerprintSHA256(k *db.PublicKey) (Fingerprint, error) {
36 		Algorithm: algo(key.Type()),
37 		Type:      "SHA256",
38 		Value:     strings.TrimPrefix(ssh.FingerprintSHA256(key), "SHA256:"),
39+		Styles:    styles,
40 	}, nil
41 }
42 
43@@ -84,7 +84,7 @@ type styledKey struct {
44 
45 func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool) styledKey {
46 	date := key.CreatedAt.Format("02 Jan 2006 15:04:05 MST")
47-	fp, err := FingerprintSHA256(key)
48+	fp, err := FingerprintSHA256(styles, key)
49 	if err != nil {
50 		fp = Fingerprint{Value: "[error generating fingerprint]"}
51 	}
52@@ -109,14 +109,14 @@ func (m Model) newStyledKey(styles common.Styles, key *db.PublicKey, active bool
53 
54 // Selected state.
55 func (k *styledKey) selected() {
56-	k.gutter = common.VerticalLine(common.StateSelected)
57+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
58 	k.keyLabel = k.styles.Label.Render("Key:")
59 	k.dateLabel = k.styles.Label.Render("Added:")
60 }
61 
62 // Deleting state.
63 func (k *styledKey) deleting() {
64-	k.gutter = common.VerticalLine(common.StateDeleting)
65+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
66 	k.keyLabel = k.styles.Delete.Render("Key:")
67 	k.dateLabel = k.styles.Delete.Render("Added:")
68 	k.dateVal = k.styles.DeleteDim.Render(k.date)
M wish/cms/ui/posts/post_view.go
+2, -2
 1@@ -54,7 +54,7 @@ func (m Model) newStyledKey(styles common.Styles, post *db.Post, urls config.Con
 2 
 3 // Selected state.
 4 func (k *styledKey) selected() {
 5-	k.gutter = common.VerticalLine(common.StateSelected)
 6+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
 7 	k.postLabel = k.styles.Label.Render("post:")
 8 	k.dateLabel = k.styles.Label.Render("publish_at:")
 9 	k.viewsLabel = k.styles.Label.Render("views:")
10@@ -64,7 +64,7 @@ func (k *styledKey) selected() {
11 
12 // Deleting state.
13 func (k *styledKey) deleting() {
14-	k.gutter = common.VerticalLine(common.StateDeleting)
15+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
16 	k.postLabel = k.styles.Delete.Render("post:")
17 	k.dateLabel = k.styles.Delete.Render("publish_at:")
18 	k.urlLabel = k.styles.Delete.Render("url:")
M wish/cms/ui/posts/posts.go
+5, -6
 1@@ -83,14 +83,13 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
 2 }
 3 
 4 // NewModel creates a new model with defaults.
 5-func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *db.User, stor storage.StorageServe, perPage int) Model {
 6+func NewModel(styles common.Styles, cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *db.User, stor storage.StorageServe, perPage int) Model {
 7 	logger := cfg.Logger
 8-	st := common.DefaultStyles()
 9 
10 	p := pager.New()
11 	p.PerPage = keysPerPage
12 	p.Type = pager.Dots
13-	p.InactiveDot = st.InactivePagination.Render("•")
14+	p.InactiveDot = styles.InactivePagination.Render("•")
15 
16 	if perPage > 0 {
17 		p.PerPage = perPage
18@@ -101,13 +100,13 @@ func NewModel(cfg *config.ConfigCms, urls config.ConfigURL, dbpool db.DB, user *
19 		dbpool:  dbpool,
20 		st:      stor,
21 		user:    user,
22-		styles:  st,
23+		styles:  styles,
24 		pager:   p,
25 		state:   stateLoading,
26 		err:     nil,
27 		posts:   []*db.Post{},
28 		index:   0,
29-		spinner: common.NewSpinner(),
30+		spinner: common.NewSpinner(styles),
31 		Exit:    false,
32 		Quit:    false,
33 		logger:  logger,
34@@ -298,7 +297,7 @@ func helpView(m Model) string {
35 		items = append(items, "x: delete")
36 	}
37 	items = append(items, "esc: exit")
38-	return common.HelpView(items...)
39+	return common.HelpView(m.styles, items...)
40 }
41 
42 func (m Model) promptView(prompt string) string {
M wish/cms/ui/tokens/tokens.go
+7, -9
 1@@ -80,26 +80,24 @@ func (m *Model) UpdatePaging(msg tea.Msg) {
 2 }
 3 
 4 // NewModel creates a new model with defaults.
 5-func NewModel(cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 6-	st := common.DefaultStyles()
 7-
 8+func NewModel(styles common.Styles, cfg *config.ConfigCms, dbpool db.DB, user *db.User) Model {
 9 	p := pager.New()
10 	p.PerPage = keysPerPage
11 	p.Type = pager.Dots
12-	p.InactiveDot = st.InactivePagination.Render("•")
13+	p.InactiveDot = styles.InactivePagination.Render("•")
14 
15 	return Model{
16 		cfg:            cfg,
17 		dbpool:         dbpool,
18 		user:           user,
19-		styles:         st,
20+		styles:         styles,
21 		pager:          p,
22 		state:          stateLoading,
23 		err:            nil,
24 		activeKeyIndex: -1,
25 		tokens:         []*db.Token{},
26 		index:          0,
27-		spinner:        common.NewSpinner(),
28+		spinner:        common.NewSpinner(styles),
29 		Exit:           false,
30 		Quit:           false,
31 	}
32@@ -209,7 +207,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
33 
34 	switch m.state {
35 	case stateNormal:
36-		m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user)
37+		m.createKey = createtoken.NewModel(m.styles, m.cfg, m.dbpool, m.user)
38 	case stateDeletingKey:
39 		// If an item is being confirmed for delete, any key (other than the key
40 		// used for confirmation above) cancels the deletion
41@@ -242,7 +240,7 @@ func updateChildren(msg tea.Msg, m Model) (Model, tea.Cmd) {
42 		m.createKey = createKeyModel
43 		cmd = newCmd
44 		if m.createKey.Done {
45-			m.createKey = createtoken.NewModel(m.cfg, m.dbpool, m.user) // reset the state
46+			m.createKey = createtoken.NewModel(m.styles, m.cfg, m.dbpool, m.user) // reset the state
47 			m.state = stateNormal
48 		} else if m.createKey.Quit {
49 			m.state = stateQuitting
50@@ -333,7 +331,7 @@ func helpView(m Model) string {
51 		items = append(items, "h/l, ←/→: page")
52 	}
53 	items = append(items, []string{"x: delete", "n: create", "esc: exit"}...)
54-	return common.HelpView(items...)
55+	return common.HelpView(m.styles, items...)
56 }
57 
58 func (m Model) promptView(prompt string) string {
M wish/cms/ui/tokens/tokenview.go
+2, -2
 1@@ -39,7 +39,7 @@ func (m Model) newStyledKey(styles common.Styles, token *db.Token, active bool)
 2 
 3 // Selected state.
 4 func (k *styledKey) selected() {
 5-	k.gutter = common.VerticalLine(common.StateSelected)
 6+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateSelected)
 7 	k.nameLabel = k.styles.Label.Render("Name:")
 8 	k.dateLabel = k.styles.Label.Render("Added:")
 9 	k.expiresLabel = k.styles.Label.Render("Expires:")
10@@ -47,7 +47,7 @@ func (k *styledKey) selected() {
11 
12 // Deleting state.
13 func (k *styledKey) deleting() {
14-	k.gutter = common.VerticalLine(common.StateDeleting)
15+	k.gutter = common.VerticalLine(k.styles.Renderer, common.StateDeleting)
16 	k.nameLabel = k.styles.Delete.Render("Name:")
17 	k.dateLabel = k.styles.Delete.Render("Added:")
18 	k.dateVal = k.styles.DeleteDim.Render(k.date)
D wish/cms/ui/username/username.go
+0, -284
  1@@ -1,284 +0,0 @@
  2-package username
  3-
  4-import (
  5-	"errors"
  6-	"fmt"
  7-	"strings"
  8-
  9-	"github.com/charmbracelet/bubbles/spinner"
 10-	input "github.com/charmbracelet/bubbles/textinput"
 11-	tea "github.com/charmbracelet/bubbletea"
 12-	"github.com/picosh/pico/db"
 13-	"github.com/picosh/pico/wish/cms/ui/common"
 14-)
 15-
 16-type state int
 17-
 18-const (
 19-	ready state = iota
 20-	submitting
 21-)
 22-
 23-// index specifies the UI element that's in focus.
 24-type index int
 25-
 26-const (
 27-	textInput index = iota
 28-	okButton
 29-	cancelButton
 30-)
 31-
 32-// NameSetMsg is sent when a new name has been set successfully. It contains
 33-// the new name.
 34-type NameSetMsg string
 35-
 36-// NameTakenMsg is sent when the requested username has already been taken.
 37-type NameTakenMsg struct{}
 38-
 39-// NameInvalidMsg is sent when the requested username has failed validation.
 40-type NameInvalidMsg struct{}
 41-
 42-type errMsg struct{ err error }
 43-
 44-func (e errMsg) Error() string { return e.err.Error() }
 45-
 46-// Model holds the state of the username UI.
 47-type Model struct {
 48-	Done bool // true when it's time to exit this view
 49-	Quit bool // true when the user wants to quit the whole program
 50-
 51-	dbpool  db.DB
 52-	user    *db.User
 53-	styles  common.Styles
 54-	state   state
 55-	newName string
 56-	index   index
 57-	errMsg  string
 58-	input   input.Model
 59-	spinner spinner.Model
 60-}
 61-
 62-// updateFocus updates the focused states in the model based on the current
 63-// focus index.
 64-func (m *Model) updateFocus() {
 65-	if m.index == textInput && !m.input.Focused() {
 66-		m.input.Focus()
 67-		m.input.Prompt = m.styles.FocusedPrompt.String()
 68-	} else if m.index != textInput && m.input.Focused() {
 69-		m.input.Blur()
 70-		m.input.Prompt = m.styles.Prompt.String()
 71-	}
 72-}
 73-
 74-// Move the focus index one unit forward.
 75-func (m *Model) indexForward() {
 76-	m.index++
 77-	if m.index > cancelButton {
 78-		m.index = textInput
 79-	}
 80-
 81-	m.updateFocus()
 82-}
 83-
 84-// Move the focus index one unit backwards.
 85-func (m *Model) indexBackward() {
 86-	m.index--
 87-	if m.index < textInput {
 88-		m.index = cancelButton
 89-	}
 90-
 91-	m.updateFocus()
 92-}
 93-
 94-// NewModel returns a new username model in its initial state.
 95-func NewModel(dbpool db.DB, user *db.User, sshUser string) Model {
 96-	st := common.DefaultStyles()
 97-
 98-	im := input.New()
 99-	im.Cursor.Style = st.Cursor
100-	im.Placeholder = sshUser
101-	im.Prompt = st.FocusedPrompt.String()
102-	im.CharLimit = 50
103-	im.Focus()
104-
105-	return Model{
106-		Done:    false,
107-		Quit:    false,
108-		dbpool:  dbpool,
109-		user:    user,
110-		styles:  st,
111-		state:   ready,
112-		newName: "",
113-		index:   textInput,
114-		errMsg:  "",
115-		input:   im,
116-		spinner: common.NewSpinner(),
117-	}
118-}
119-
120-// Init is the Bubble Tea initialization function.
121-func Init(dbpool db.DB, user *db.User, sshUser string) func() (Model, tea.Cmd) {
122-	return func() (Model, tea.Cmd) {
123-		m := NewModel(dbpool, user, sshUser)
124-		return m, InitialCmd()
125-	}
126-}
127-
128-// InitialCmd returns the initial command.
129-func InitialCmd() tea.Cmd {
130-	return input.Blink
131-}
132-
133-// Update is the Bubble Tea update loop.
134-func Update(msg tea.Msg, m Model) (Model, tea.Cmd) {
135-	switch msg := msg.(type) {
136-	case tea.KeyMsg:
137-		switch msg.Type {
138-		case tea.KeyCtrlC: // quit
139-			m.Quit = true
140-			return m, nil
141-		case tea.KeyEscape: // exit this mini-app
142-			m.Done = true
143-			return m, nil
144-
145-		default:
146-			// Ignore keys if we're submitting
147-			if m.state == submitting {
148-				return m, nil
149-			}
150-
151-			switch msg.String() {
152-			case "tab":
153-				m.indexForward()
154-			case "shift+tab":
155-				m.indexBackward()
156-			case "l", "k", "right":
157-				if m.index != textInput {
158-					m.indexForward()
159-				}
160-			case "h", "j", "left":
161-				if m.index != textInput {
162-					m.indexBackward()
163-				}
164-			case "up", "down":
165-				if m.index == textInput {
166-					m.indexForward()
167-				} else {
168-					m.index = textInput
169-					m.updateFocus()
170-				}
171-			case "enter":
172-				switch m.index {
173-				case textInput:
174-					fallthrough
175-				case okButton: // Submit the form
176-					m.state = submitting
177-					m.errMsg = ""
178-					m.newName = strings.TrimSpace(m.input.Value())
179-
180-					return m, tea.Batch(
181-						setName(m), // fire off the command, too
182-						m.spinner.Tick,
183-					)
184-				case cancelButton: // Exit this mini-app
185-					m.Done = true
186-					return m, nil
187-				}
188-			}
189-
190-			// Pass messages through to the input element if that's the element
191-			// in focus
192-			if m.index == textInput {
193-				var cmd tea.Cmd
194-				m.input, cmd = m.input.Update(msg)
195-
196-				return m, cmd
197-			}
198-
199-			return m, nil
200-		}
201-
202-	case NameTakenMsg:
203-		m.state = ready
204-		m.errMsg = m.styles.Subtle.Render("Sorry, ") +
205-			m.styles.Error.Render(m.newName) +
206-			m.styles.Subtle.Render(" is taken.")
207-
208-		return m, nil
209-
210-	case NameInvalidMsg:
211-		m.state = ready
212-		head := m.styles.Error.Render("Invalid name. ")
213-		deny := strings.Join(db.DenyList, ", ")
214-		helpMsg := fmt.Sprintf("Names can only contain plain letters and numbers and must be less than 50 characters. No emjois. No names from deny list: %s", deny)
215-		body := m.styles.Subtle.Render(helpMsg)
216-		m.errMsg = m.styles.Wrap.Render(head + body)
217-
218-		return m, nil
219-
220-	case errMsg:
221-		m.state = ready
222-		head := m.styles.Error.Render("Oh, what? There was a curious error we were not expecting. ")
223-		body := m.styles.Subtle.Render(msg.Error())
224-		m.errMsg = m.styles.Wrap.Render(head + body)
225-
226-		return m, nil
227-
228-	case spinner.TickMsg:
229-		var cmd tea.Cmd
230-		m.spinner, cmd = m.spinner.Update(msg)
231-
232-		return m, cmd
233-
234-	default:
235-		var cmd tea.Cmd
236-		m.input, cmd = m.input.Update(msg) // Do we still need this?
237-
238-		return m, cmd
239-	}
240-}
241-
242-// View renders current view from the model.
243-func View(m Model) string {
244-	s := "Enter a new username\n\n"
245-	s += m.input.View() + "\n\n"
246-
247-	if m.state == submitting {
248-		s += spinnerView(m)
249-	} else {
250-		s += common.OKButtonView(m.index == 1, true)
251-		s += " " + common.CancelButtonView(m.index == 2, false)
252-		if m.errMsg != "" {
253-			s += "\n\n" + m.errMsg
254-		}
255-	}
256-
257-	return s
258-}
259-
260-func spinnerView(m Model) string {
261-	return m.spinner.View() + " Submitting..."
262-}
263-
264-// Attempt to update the username on the server.
265-func setName(m Model) tea.Cmd {
266-	return func() tea.Msg {
267-		valid, err := m.dbpool.ValidateName(m.newName)
268-		// Validate before resetting the session to potentially save some
269-		// network traffic and keep things feeling speedy.
270-		if !valid {
271-			if errors.Is(err, db.ErrNameTaken) {
272-				return NameTakenMsg{}
273-			} else {
274-				return NameInvalidMsg{}
275-			}
276-		}
277-
278-		err = m.dbpool.SetUserName(m.user.ID, m.newName)
279-		if err != nil {
280-			return errMsg{err}
281-		}
282-
283-		return NameSetMsg(m.newName)
284-	}
285-}